Skip to content

Commit 28cf761

Browse files
committed
Return [] | None on MCP resource methods when not found or no capabilities.
1 parent 918f85c commit 28cf761

File tree

3 files changed

+65
-26
lines changed

3 files changed

+65
-26
lines changed

pydantic_ai_slim/pydantic_ai/exceptions.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
'UsageLimitExceeded',
2525
'ModelHTTPError',
2626
'MCPError',
27-
'MCPServerCapabilitiesError',
2827
'MCPServerError',
2928
'IncompleteToolCall',
3029
'FallbackExceptionGroup',
@@ -176,10 +175,6 @@ def __str__(self) -> str:
176175
return self.message
177176

178177

179-
class MCPServerCapabilitiesError(MCPError):
180-
"""Raised when attempting to access server capabilities that aren't present."""
181-
182-
183178
class MCPServerError(MCPError):
184179
"""Raised when an MCP server returns an error response.
185180

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
# after mcp imports so any import error maps to this file, not _mcp.py
4444
from . import _mcp, _utils, exceptions, messages, models
45-
from .exceptions import MCPServerCapabilitiesError, MCPServerError
45+
from .exceptions import MCPServerError
4646

4747
__all__ = 'MCPServer', 'MCPServerStdio', 'MCPServerHTTP', 'MCPServerSSE', 'MCPServerStreamableHTTP', 'load_mcp_servers'
4848

@@ -322,12 +322,11 @@ async def list_resources(self) -> list[_mcp.Resource]:
322322
- We also don't subscribe to resource changes to avoid complexity.
323323
324324
Raises:
325-
MCPServerCapabilitiesError: If the server does not support resources.
326325
MCPServerError: If the server returns an error.
327326
"""
328327
async with self: # Ensure server is running
329328
if not self.capabilities.resources:
330-
raise MCPServerCapabilitiesError('Server does not support resources capability')
329+
return []
331330
try:
332331
result = await self._client.list_resources()
333332
except mcp_exceptions.McpError as e:
@@ -338,29 +337,30 @@ async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]:
338337
"""Retrieve resource templates that are currently present on the server.
339338
340339
Raises:
341-
MCPServerCapabilitiesError: If the server does not support resources.
342340
MCPServerError: If the server returns an error.
343341
"""
344342
async with self: # Ensure server is running
345343
if not self.capabilities.resources:
346-
raise MCPServerCapabilitiesError('Server does not support resources capability')
344+
return []
347345
try:
348346
result = await self._client.list_resource_templates()
349347
except mcp_exceptions.McpError as e:
350348
raise MCPServerError.from_mcp_sdk_error(e) from e
351349
return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates]
352350

353351
@overload
354-
async def read_resource(self, uri: str) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ...
352+
async def read_resource(
353+
self, uri: str
354+
) -> str | messages.BinaryContent | list[str | messages.BinaryContent] | None: ...
355355

356356
@overload
357357
async def read_resource(
358358
self, uri: _mcp.Resource
359-
) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ...
359+
) -> str | messages.BinaryContent | list[str | messages.BinaryContent] | None: ...
360360

361361
async def read_resource(
362362
self, uri: str | _mcp.Resource
363-
) -> str | messages.BinaryContent | list[str | messages.BinaryContent]:
363+
) -> str | messages.BinaryContent | list[str | messages.BinaryContent] | None:
364364
"""Read the contents of a specific resource by URI.
365365
366366
Args:
@@ -369,19 +369,26 @@ async def read_resource(
369369
Returns:
370370
The resource contents. If the resource has a single content item, returns that item directly.
371371
If the resource has multiple content items, returns a list of items.
372+
Returns `None` if the server does not support resources or the resource is not found.
372373
373374
Raises:
374-
MCPServerCapabilitiesError: If the server does not support resources.
375-
MCPServerError: If the server returns an error (e.g., resource not found).
375+
MCPServerError: If the server returns an error other than resource not found.
376376
"""
377377
resource_uri = uri if isinstance(uri, str) else uri.uri
378378
async with self: # Ensure server is running
379379
if not self.capabilities.resources:
380-
raise MCPServerCapabilitiesError('Server does not support resources capability')
380+
return None
381381
try:
382382
result = await self._client.read_resource(AnyUrl(resource_uri))
383383
except mcp_exceptions.McpError as e:
384+
# As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling
385+
if e.error.code == -32002:
386+
return None
384387
raise MCPServerError.from_mcp_sdk_error(e) from e
388+
389+
if not result.contents:
390+
return None
391+
385392
return (
386393
self._get_content(result.contents[0])
387394
if len(result.contents) == 1
@@ -483,7 +490,9 @@ async def _map_tool_result_part(
483490
resource = part.resource
484491
return self._get_content(resource)
485492
elif isinstance(part, mcp_types.ResourceLink):
486-
return await self.read_resource(str(part.uri))
493+
result = await self.read_resource(str(part.uri))
494+
# If resource not found, return an empty string as it's impossible to fetch anyway
495+
return result if result is not None else ''
487496
else:
488497
assert_never(part)
489498

tests/test_mcp.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
from pydantic_ai._mcp import Resource, ServerCapabilities
2727
from pydantic_ai.agent import Agent
2828
from pydantic_ai.exceptions import (
29-
MCPServerCapabilitiesError,
3029
MCPServerError,
3130
ModelRetry,
3231
UnexpectedModelBehavior,
@@ -1546,8 +1545,9 @@ async def test_read_resource_template(run_context: RunContext[int]):
15461545

15471546

15481547
async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None:
1549-
"""Test that read_resource raises MCPServerError for non-existent resources."""
1548+
"""Test that read_resource raises MCPServerError for non-existent resources with non-standard error codes."""
15501549
async with mcp_server:
1550+
# FastMCP uses error code 0 instead of -32002, so it should raise
15511551
with pytest.raises(MCPServerError, match='Unknown resource: resource://does_not_exist') as exc_info:
15521552
await mcp_server.read_resource('resource://does_not_exist')
15531553

@@ -1556,6 +1556,38 @@ async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None:
15561556
assert exc_info.value.message == 'Unknown resource: resource://does_not_exist'
15571557

15581558

1559+
async def test_read_resource_not_found_mcp_spec(mcp_server: MCPServerStdio) -> None:
1560+
"""Test that read_resource returns None for MCP spec error code -32002 (resource not found)."""
1561+
# As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling
1562+
mcp_error = McpError(error=ErrorData(code=-32002, message='Resource not found'))
1563+
1564+
async with mcp_server:
1565+
with patch.object(
1566+
mcp_server._client, # pyright: ignore[reportPrivateUsage]
1567+
'read_resource',
1568+
new=AsyncMock(side_effect=mcp_error),
1569+
):
1570+
result = await mcp_server.read_resource('resource://missing')
1571+
assert result is None
1572+
1573+
1574+
async def test_read_resource_empty_contents(mcp_server: MCPServerStdio) -> None:
1575+
"""Test that read_resource returns None when server returns empty contents."""
1576+
from mcp.types import ReadResourceResult
1577+
1578+
# Mock a result with empty contents
1579+
empty_result = ReadResourceResult(contents=[])
1580+
1581+
async with mcp_server:
1582+
with patch.object(
1583+
mcp_server._client, # pyright: ignore[reportPrivateUsage]
1584+
'read_resource',
1585+
new=AsyncMock(return_value=empty_result),
1586+
):
1587+
result = await mcp_server.read_resource('resource://empty')
1588+
assert result is None
1589+
1590+
15591591
async def test_list_resources_error(mcp_server: MCPServerStdio) -> None:
15601592
"""Test that list_resources converts McpError to MCPServerError."""
15611593
mcp_error = McpError(
@@ -1645,16 +1677,19 @@ async def test_capabilities(mcp_server: MCPServerStdio) -> None:
16451677

16461678

16471679
async def test_resource_methods_without_capability(mcp_server: MCPServerStdio) -> None:
1648-
"""Test that resource methods raise MCPServerCapabilitiesError when resources capability is not available."""
1680+
"""Test that resource methods return empty values when resources capability is not available."""
16491681
async with mcp_server:
16501682
# Mock the capabilities to not support resources
16511683
mock_capabilities = ServerCapabilities(resources=False)
16521684
with patch.object(mcp_server, '_server_capabilities', mock_capabilities):
1653-
with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'):
1654-
await mcp_server.list_resources()
1685+
# list_resources should return empty list
1686+
result = await mcp_server.list_resources()
1687+
assert result == []
16551688

1656-
with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'):
1657-
await mcp_server.list_resource_templates()
1689+
# list_resource_templates should return empty list
1690+
result = await mcp_server.list_resource_templates()
1691+
assert result == []
16581692

1659-
with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'):
1660-
await mcp_server.read_resource('resource://test')
1693+
# read_resource should return None
1694+
result = await mcp_server.read_resource('resource://test')
1695+
assert result is None

0 commit comments

Comments
 (0)