From 9fde764ed1d583a93314a23a1ddc697c604c6589 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 29 Sep 2025 21:26:07 +1000 Subject: [PATCH 01/32] Add initial list_resources() method to MCPServer. --- pydantic_ai_slim/pydantic_ai/mcp.py | 11 +++++++++++ tests/test_mcp.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index b61f254500..c296d7cf5d 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -275,6 +275,17 @@ def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[Any]: args_validator=TOOL_SCHEMA_VALIDATOR, ) + async def list_resources(self) -> list[mcp_types.Resource]: + """Retrieve resources that are currently active on the server. + + Note: + - We don't cache resources as they might change. + - We also don't subscribe to resource changes to avoid complexity. + """ + async with self: # Ensure server is running + result = await self._client.list_resources() + return result.resources + async def __aenter__(self) -> Self: """Enter the MCP server context. diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b6d99249c7..ed52a2e17b 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -318,6 +318,25 @@ async def test_log_level_unset(run_context: RunContext[int]): assert result == snapshot('unset') +async def test_stdio_server_list_resources(run_context: RunContext[int]): + server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) + async with server: + resources = await server.list_resources() + assert len(resources) == snapshot(3) + + assert resources[0].uri == snapshot('resource://kiwi.png') + assert resources[0].mimeType == snapshot('image/png') + assert resources[0].name == snapshot('kiwi_resource') + + assert resources[1].uri == snapshot('resource://marcelo.mp3') + assert resources[1].mimeType == snapshot('audio/mpeg') + assert resources[1].name == snapshot('marcelo_resource') + + assert resources[2].uri == snapshot('resource://product_name.txt') + assert resources[2].mimeType == snapshot('text/plain') + assert resources[2].name == snapshot('product_name_resource') + + async def test_log_level_set(run_context: RunContext[int]): server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], log_level='info') assert server.log_level == 'info' From a7404a2c6a42dbc4dc0bfed10c720ba96ae3bd77 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 29 Sep 2025 21:50:33 +1000 Subject: [PATCH 02/32] Add list_resource_templates() to MCPServer. --- pydantic_ai_slim/pydantic_ai/mcp.py | 8 +++++++- tests/mcp_server.py | 6 ++++++ tests/test_mcp.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index c296d7cf5d..1f1c8c9438 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -276,7 +276,7 @@ def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[Any]: ) async def list_resources(self) -> list[mcp_types.Resource]: - """Retrieve resources that are currently active on the server. + """Retrieve resources that are currently present on the server. Note: - We don't cache resources as they might change. @@ -286,6 +286,12 @@ async def list_resources(self) -> list[mcp_types.Resource]: result = await self._client.list_resources() return result.resources + async def list_resource_templates(self) -> list[mcp_types.ResourceTemplate]: + """Retrieve resource templates that are currently present on the server.""" + async with self: # Ensure server is running + result = await self._client.list_resource_templates() + return result.resourceTemplates + async def __aenter__(self) -> Self: """Enter the MCP server context. diff --git a/tests/mcp_server.py b/tests/mcp_server.py index e7beef66d8..ef47ca81f3 100644 --- a/tests/mcp_server.py +++ b/tests/mcp_server.py @@ -124,6 +124,12 @@ async def product_name_resource() -> str: return Path(__file__).parent.joinpath('assets/product_name.txt').read_text() +@mcp.resource('resource://greeting/{name}', mime_type='text/plain') +async def greeting_resource_template(name: str) -> str: + """Dynamic greeting resource template.""" + return f'Hello, {name}!' + + @mcp.tool() async def get_image() -> Image: data = Path(__file__).parent.joinpath('assets/kiwi.png').read_bytes() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index ed52a2e17b..bee477eb28 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -337,6 +337,17 @@ async def test_stdio_server_list_resources(run_context: RunContext[int]): assert resources[2].name == snapshot('product_name_resource') +async def test_stdio_server_list_resource_templates(run_context: RunContext[int]): + server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) + async with server: + resource_templates = await server.list_resource_templates() + assert len(resource_templates) == snapshot(1) + + assert resource_templates[0].uriTemplate == snapshot('resource://greeting/{name}') + assert resource_templates[0].name == snapshot('greeting_resource_template') + assert resource_templates[0].description == snapshot('Dynamic greeting resource template.') + + async def test_log_level_set(run_context: RunContext[int]): server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], log_level='info') assert server.log_level == 'info' From ecea4665f6d041842bf5992e05f89f8617a28d00 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 29 Sep 2025 22:13:03 +1000 Subject: [PATCH 03/32] Workaround limitations with inline-snapshot & AnyUrl. --- tests/test_mcp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index bee477eb28..cc3255101c 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -324,15 +324,15 @@ async def test_stdio_server_list_resources(run_context: RunContext[int]): resources = await server.list_resources() assert len(resources) == snapshot(3) - assert resources[0].uri == snapshot('resource://kiwi.png') + assert str(resources[0].uri) == snapshot('resource://kiwi.png') assert resources[0].mimeType == snapshot('image/png') assert resources[0].name == snapshot('kiwi_resource') - assert resources[1].uri == snapshot('resource://marcelo.mp3') + assert str(resources[1].uri) == snapshot('resource://marcelo.mp3') assert resources[1].mimeType == snapshot('audio/mpeg') assert resources[1].name == snapshot('marcelo_resource') - assert resources[2].uri == snapshot('resource://product_name.txt') + assert str(resources[2].uri) == snapshot('resource://product_name.txt') assert resources[2].mimeType == snapshot('text/plain') assert resources[2].name == snapshot('product_name_resource') From 18862997b3bf15446757478bfb4e744b31f6cec9 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 29 Sep 2025 22:41:23 +1000 Subject: [PATCH 04/32] Add basic read_resource() to MCPServer. --- pydantic_ai_slim/pydantic_ai/mcp.py | 15 +++++++- tests/test_mcp.py | 56 ++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 1f1c8c9438..078e89b6ab 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -16,7 +16,7 @@ import httpx import pydantic_core from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import BaseModel, Discriminator, Field, Tag +from pydantic import AnyUrl, BaseModel, Discriminator, Field, Tag from pydantic_core import CoreSchema, core_schema from typing_extensions import Self, assert_never, deprecated @@ -292,6 +292,19 @@ async def list_resource_templates(self) -> list[mcp_types.ResourceTemplate]: result = await self._client.list_resource_templates() return result.resourceTemplates + async def read_resource(self, uri: str) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: + """Read the contents of a specific resource by URI. + + Args: + uri: The URI of the resource to read. + + Returns: + A list of resource contents (either TextResourceContents or BlobResourceContents). + """ + async with self: # Ensure server is running + result = await self._client.read_resource(AnyUrl(uri)) + return result.contents + async def __aenter__(self) -> Self: """Enter the MCP server context. diff --git a/tests/test_mcp.py b/tests/test_mcp.py index cc3255101c..8b046456a9 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -37,7 +37,15 @@ from mcp import ErrorData, McpError, SamplingMessage from mcp.client.session import ClientSession from mcp.shared.context import RequestContext - from mcp.types import CreateMessageRequestParams, ElicitRequestParams, ElicitResult, ImageContent, TextContent + from mcp.types import ( + BlobResourceContents, + CreateMessageRequestParams, + ElicitRequestParams, + ElicitResult, + ImageContent, + TextContent, + TextResourceContents, + ) from pydantic_ai._mcp import map_from_mcp_params, map_from_model_response from pydantic_ai.mcp import CallToolFunc, MCPServerSSE, MCPServerStdio, ToolResult @@ -1424,6 +1432,52 @@ async def test_elicitation_callback_not_set(run_context: RunContext[int]): await server.direct_call_tool('use_elicitation', {'question': 'Should I continue?'}) +async def test_read_text_resource(run_context: RunContext[int]): + """Test reading a text resource (TextResourceContents).""" + server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) + async with server: + contents = await server.read_resource('resource://product_name.txt') + assert len(contents) == snapshot(1) + + content = contents[0] + assert str(content.uri) == snapshot('resource://product_name.txt') + assert content.mimeType == snapshot('text/plain') + assert isinstance(content, TextResourceContents) + assert content.text == snapshot('Pydantic AI\n') + + +async def test_read_blob_resource(run_context: RunContext[int]): + """Test reading a binary resource (BlobResourceContents).""" + server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) + async with server: + contents = await server.read_resource('resource://kiwi.png') + assert len(contents) == snapshot(1) + + content = contents[0] + assert str(content.uri) == snapshot('resource://kiwi.png') + assert content.mimeType == snapshot('image/png') + assert isinstance(content, BlobResourceContents) + # blob should be base64 encoded string + assert isinstance(content.blob, str) + # Decode and verify it's PNG data (starts with PNG magic bytes) + decoded_data = base64.b64decode(content.blob) + assert decoded_data[:8] == b'\x89PNG\r\n\x1a\n' # PNG magic bytes + + +async def test_read_resource_template(run_context: RunContext[int]): + """Test reading a resource template with parameters (TextResourceContents).""" + server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) + async with server: + contents = await server.read_resource('resource://greeting/Alice') + assert len(contents) == snapshot(1) + + content = contents[0] + assert str(content.uri) == snapshot('resource://greeting/Alice') + assert content.mimeType == snapshot('text/plain') + assert isinstance(content, TextResourceContents) + assert content.text == snapshot('Hello, Alice!') + + def test_load_mcp_servers(tmp_path: Path): config = tmp_path / 'mcp.json' From dfb54dbb8e345653b023fdc71b466b7168d7f50f Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 29 Sep 2025 23:23:36 +1000 Subject: [PATCH 05/32] Update mcp client docs to add Resources section. --- docs/mcp/client.md | 108 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 668cb91750..ba91b08899 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -322,6 +322,114 @@ agent = Agent('openai:gpt-4o', toolsets=[weather_server, calculator_server]) MCP tools can include metadata that provides additional information about the tool's characteristics, which can be useful when [filtering tools][pydantic_ai.toolsets.FilteredToolset]. The `meta`, `annotations`, and `output_schema` fields can be found on the `metadata` dict on the [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] object that's passed to filter functions. +## Resources + +MCP servers can provide [resources](https://modelcontextprotocol.io/docs/concepts/resources) - files, data, or content that can be accessed by the client. Resources in MCP are designed to be application-driven, with host applications determining how to incorporate context based on their needs. + +Pydantic AI provides methods to discover and read resources from MCP servers: + +- [`list_resources()`][pydantic_ai.mcp.MCPServer.list_resources] - List all available resources on the server +- [`list_resource_templates()`][pydantic_ai.mcp.MCPServer.list_resource_templates] - List resource templates with parameter placeholders +- [`read_resource(uri)`][pydantic_ai.mcp.MCPServer.read_resource] - Read the contents of a specific resource by URI + +Resources can contain either text content ([`TextResourceContents`][mcp.types.TextResourceContents]) or binary content ([`BlobResourceContents`][mcp.types.BlobResourceContents]) encoded as base64. + +```python {title="mcp_resources.py"} +import asyncio +import base64 +from dataclasses import dataclass + +from mcp.types import BlobResourceContents, TextResourceContents +from pydantic_ai import Agent +from pydantic_ai._run_context import RunContext +from pydantic_ai.mcp import MCPServerStdio +from pydantic_ai.models.test import TestModel + + +@dataclass +class Deps: + product_name: str + + +agent = Agent( + model=TestModel(), + deps_type=Deps, + instructions='Be sure to give advice related to the users product.', +) + + +@agent.instructions +def add_the_users_product(ctx: RunContext[Deps]) -> str: + return f"The user's product is {ctx.deps.product_name}." + + +async def main(): + server = MCPServerStdio('python', args=['-m', 'tests.mcp_server']) + + async with server: + # List all available resources + resources = await server.list_resources() + print(f'Found {len(resources)} resources:') + for resource in resources: + print(f' - {resource.name}: {resource.uri} ({resource.mimeType})') + + # List resource templates (with parameters) + templates = await server.list_resource_templates() + print(f'\nFound {len(templates)} resource templates:') + for template in templates: + print(f' - {template.name}: {template.uriTemplate}') + + # Read a text resource + text_contents = await server.read_resource('resource://product_name.txt') + for content in text_contents: + if isinstance(content, TextResourceContents): + print(f'\nText content from {content.uri}: {content.text.strip()}') + + # Read a binary resource + binary_contents = await server.read_resource('resource://kiwi.png') + for content in binary_contents: + if isinstance(content, BlobResourceContents): + binary_data = base64.b64decode(content.blob) + print(f'\nBinary content from {content.uri}: {len(binary_data)} bytes') + + # Read from a resource template with parameters + greeting_contents = await server.read_resource('resource://greeting/Alice') + for content in greeting_contents: + if isinstance(content, TextResourceContents): + print(f'\nTemplate content: {content.text}') + + # Use resources in dependencies + async with agent: + product_name = text_contents[0].text + deps = Deps(product_name=product_name) + print(f'\nDeps: {deps}') + result = await agent.run('Can you help me with my product?', deps=deps) + print(result.output) + + +if __name__ == '__main__': + asyncio.run(main()) + + #> Found 3 resources: + #> - kiwi_resource: resource://kiwi.png (image/png) + #> - marcelo_resource: resource://marcelo.mp3 (audio/mpeg) + #> - product_name_resource: resource://product_name.txt (text/plain) + #> + #> Found 1 resource templates: + #> - greeting_resource_template: resource://greeting/{name} + #> + #> Text content from resource://product_name.txt: Pydantic AI + #> + #> Binary content from resource://kiwi.png: 2084609 bytes + #> + #> Template content: Hello, Alice! + #> + #> Deps: Deps(product_name='Pydantic AI\n') +``` + +_(This example is complete, it can be run "as is")_ + + ## Custom TLS / SSL configuration In some environments you need to tweak how HTTPS connections are established – From f52bb71a71f6b6a72fbc5e05925dcbcc2f3eb73b Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 29 Sep 2025 23:35:22 +1000 Subject: [PATCH 06/32] Organize imports in doc examples. --- docs/mcp/client.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index ba91b08899..c880a354f3 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -340,6 +340,7 @@ import base64 from dataclasses import dataclass from mcp.types import BlobResourceContents, TextResourceContents + from pydantic_ai import Agent from pydantic_ai._run_context import RunContext from pydantic_ai.mcp import MCPServerStdio From 232ff04da09228e61acbeb7d6d771a1d7ba58406 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Tue, 30 Sep 2025 00:23:53 +1000 Subject: [PATCH 07/32] Update MCP resource example docs. --- docs/mcp/client.md | 93 +++++++++++++++++----------------------------- 1 file changed, 34 insertions(+), 59 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index c880a354f3..0cf62dee55 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -334,98 +334,73 @@ Pydantic AI provides methods to discover and read resources from MCP servers: Resources can contain either text content ([`TextResourceContents`][mcp.types.TextResourceContents]) or binary content ([`BlobResourceContents`][mcp.types.BlobResourceContents]) encoded as base64. -```python {title="mcp_resources.py"} +Before consuming resources, we need to run a server that exposes some: + +```python {title="mcp_resource_server.py"} +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP('Pydantic AI MCP Server') +log_level = 'unset' + + +@mcp.resource('resource://user_name.txt', mime_type='text/plain') +async def user_name_resource() -> str: + return 'Alice' + + +if __name__ == '__main__': + mcp.run() +``` + +Then we can create the client: + +```python {title="mcp_resources.py", requires="mcp_resource_server.py"} import asyncio -import base64 -from dataclasses import dataclass -from mcp.types import BlobResourceContents, TextResourceContents +from mcp.types import TextResourceContents from pydantic_ai import Agent from pydantic_ai._run_context import RunContext from pydantic_ai.mcp import MCPServerStdio from pydantic_ai.models.test import TestModel - -@dataclass -class Deps: - product_name: str - - agent = Agent( model=TestModel(), - deps_type=Deps, - instructions='Be sure to give advice related to the users product.', + deps_type=str, + instructions="Use the customer's name while replying to them.", ) @agent.instructions -def add_the_users_product(ctx: RunContext[Deps]) -> str: - return f"The user's product is {ctx.deps.product_name}." +def add_the_users_name(ctx: RunContext[str]) -> str: + return f"The user's name is {ctx.deps}." async def main(): - server = MCPServerStdio('python', args=['-m', 'tests.mcp_server']) + server = MCPServerStdio('python', args=['-m', 'mcp_resource_server']) async with server: # List all available resources resources = await server.list_resources() - print(f'Found {len(resources)} resources:') for resource in resources: - print(f' - {resource.name}: {resource.uri} ({resource.mimeType})') - - # List resource templates (with parameters) - templates = await server.list_resource_templates() - print(f'\nFound {len(templates)} resource templates:') - for template in templates: - print(f' - {template.name}: {template.uriTemplate}') + print(f' - {resource.name}: {resource.uri} ({resource.mimeType})') + #> - user_name_resource: resource://user_name.txt (text/plain) # Read a text resource - text_contents = await server.read_resource('resource://product_name.txt') + text_contents = await server.read_resource('resource://user_name.txt') for content in text_contents: if isinstance(content, TextResourceContents): - print(f'\nText content from {content.uri}: {content.text.strip()}') - - # Read a binary resource - binary_contents = await server.read_resource('resource://kiwi.png') - for content in binary_contents: - if isinstance(content, BlobResourceContents): - binary_data = base64.b64decode(content.blob) - print(f'\nBinary content from {content.uri}: {len(binary_data)} bytes') - - # Read from a resource template with parameters - greeting_contents = await server.read_resource('resource://greeting/Alice') - for content in greeting_contents: - if isinstance(content, TextResourceContents): - print(f'\nTemplate content: {content.text}') + print(f'Text content from {content.uri}: {content.text.strip()}') + #> Text content from resource://user_name.txt: Alice # Use resources in dependencies async with agent: - product_name = text_contents[0].text - deps = Deps(product_name=product_name) - print(f'\nDeps: {deps}') - result = await agent.run('Can you help me with my product?', deps=deps) - print(result.output) + user_name = text_contents[0].text + _ = await agent.run('Can you help me with my product?', deps=user_name) if __name__ == '__main__': asyncio.run(main()) - - #> Found 3 resources: - #> - kiwi_resource: resource://kiwi.png (image/png) - #> - marcelo_resource: resource://marcelo.mp3 (audio/mpeg) - #> - product_name_resource: resource://product_name.txt (text/plain) - #> - #> Found 1 resource templates: - #> - greeting_resource_template: resource://greeting/{name} - #> - #> Text content from resource://product_name.txt: Pydantic AI - #> - #> Binary content from resource://kiwi.png: 2084609 bytes - #> - #> Template content: Hello, Alice! - #> - #> Deps: Deps(product_name='Pydantic AI\n') ``` _(This example is complete, it can be run "as is")_ From 317416c0566058e303d7a5b548113e7f42ef22f1 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 6 Oct 2025 22:03:27 +1100 Subject: [PATCH 08/32] Create native Resource and ResourceTemplate types and update MCPServer to return these in preference to `mcp` upstream types. --- docs/mcp/client.md | 2 +- pydantic_ai_slim/pydantic_ai/_mcp.py | 77 +++++++++++++++++++++++++++- pydantic_ai_slim/pydantic_ai/mcp.py | 8 +-- tests/test_mcp.py | 14 ++--- 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 4d9e558e6f..fcc1b6f510 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -379,7 +379,7 @@ async def main(): # List all available resources resources = await server.list_resources() for resource in resources: - print(f' - {resource.name}: {resource.uri} ({resource.mimeType})') + print(f' - {resource.name}: {resource.uri} ({resource.mime_type})') #> - user_name_resource: resource://user_name.txt (text/plain) # Read a text resource diff --git a/pydantic_ai_slim/pydantic_ai/_mcp.py b/pydantic_ai_slim/pydantic_ai/_mcp.py index 1e09246ccc..9475b407c9 100644 --- a/pydantic_ai_slim/pydantic_ai/_mcp.py +++ b/pydantic_ai_slim/pydantic_ai/_mcp.py @@ -1,8 +1,10 @@ import base64 +from abc import ABC from collections.abc import Sequence -from typing import Literal +from dataclasses import dataclass +from typing import Any, Literal -from . import exceptions, messages +from . import _utils, exceptions, messages try: from mcp import types as mcp_types @@ -13,6 +15,50 @@ ) from _import_error +@dataclass(repr=False, kw_only=True) +class BaseResource(ABC): + """Base class for MCP resources.""" + + name: str + """The programmatic name of the resource.""" + + title: str | None = None + """Human-readable title for UI contexts.""" + + description: str | None = None + """A description of what this resource represents.""" + + mime_type: str | None = None + """The MIME type of the resource, if known.""" + + annotations: dict[str, Any] | None = None + """Optional annotations for the resource.""" + + meta: dict[str, Any] | None = None + """Optional metadata for the resource.""" + + __repr__ = _utils.dataclasses_no_defaults_repr + + +@dataclass(repr=False, kw_only=True) +class Resource(BaseResource): + """A resource that can be read from an MCP server.""" + + uri: str + """The URI of the resource.""" + + size: int | None = None + """The size of the raw resource content in bytes (before base64 encoding), if known.""" + + +@dataclass(repr=False, kw_only=True) +class ResourceTemplate(BaseResource): + """A template for parameterized resources on an MCP server.""" + + uri_template: str + """URI template (RFC 6570) for constructing resource URIs.""" + + def map_from_mcp_params(params: mcp_types.CreateMessageRequestParams) -> list[messages.ModelMessage]: """Convert from MCP create message request parameters to pydantic-ai messages.""" pai_messages: list[messages.ModelMessage] = [] @@ -121,3 +167,30 @@ def map_from_sampling_content( return messages.TextPart(content=content.text) else: raise NotImplementedError('Image and Audio responses in sampling are not yet supported') + + +def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> Resource: + """Convert from MCP Resource to native Pydantic AI Resource.""" + return Resource( + uri=str(mcp_resource.uri), + name=mcp_resource.name, + title=mcp_resource.title, + description=mcp_resource.description, + mime_type=mcp_resource.mimeType, + size=mcp_resource.size, + annotations=mcp_resource.annotations.model_dump() if mcp_resource.annotations else None, + meta=mcp_resource.meta, + ) + + +def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> ResourceTemplate: + """Convert from MCP ResourceTemplate to native Pydantic AI ResourceTemplate.""" + return ResourceTemplate( + uri_template=mcp_template.uriTemplate, + name=mcp_template.name, + title=mcp_template.title, + description=mcp_template.description, + mime_type=mcp_template.mimeType, + annotations=mcp_template.annotations.model_dump() if mcp_template.annotations else None, + meta=mcp_template.meta, + ) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index e39ce6f354..b40fc59ce8 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -303,7 +303,7 @@ def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[Any]: args_validator=TOOL_SCHEMA_VALIDATOR, ) - async def list_resources(self) -> list[mcp_types.Resource]: + async def list_resources(self) -> list[_mcp.Resource]: """Retrieve resources that are currently present on the server. Note: @@ -312,13 +312,13 @@ async def list_resources(self) -> list[mcp_types.Resource]: """ async with self: # Ensure server is running result = await self._client.list_resources() - return result.resources + return [_mcp.map_from_mcp_resource(r) for r in result.resources] - async def list_resource_templates(self) -> list[mcp_types.ResourceTemplate]: + async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: """Retrieve resource templates that are currently present on the server.""" async with self: # Ensure server is running result = await self._client.list_resource_templates() - return result.resourceTemplates + return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] async def read_resource(self, uri: str) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: """Read the contents of a specific resource by URI. diff --git a/tests/test_mcp.py b/tests/test_mcp.py index e925f5db14..735082e6e1 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -328,16 +328,16 @@ async def test_stdio_server_list_resources(run_context: RunContext[int]): resources = await server.list_resources() assert len(resources) == snapshot(3) - assert str(resources[0].uri) == snapshot('resource://kiwi.png') - assert resources[0].mimeType == snapshot('image/png') + assert resources[0].uri == snapshot('resource://kiwi.png') + assert resources[0].mime_type == snapshot('image/png') assert resources[0].name == snapshot('kiwi_resource') - assert str(resources[1].uri) == snapshot('resource://marcelo.mp3') - assert resources[1].mimeType == snapshot('audio/mpeg') + assert resources[1].uri == snapshot('resource://marcelo.mp3') + assert resources[1].mime_type == snapshot('audio/mpeg') assert resources[1].name == snapshot('marcelo_resource') - assert str(resources[2].uri) == snapshot('resource://product_name.txt') - assert resources[2].mimeType == snapshot('text/plain') + assert resources[2].uri == snapshot('resource://product_name.txt') + assert resources[2].mime_type == snapshot('text/plain') assert resources[2].name == snapshot('product_name_resource') @@ -347,7 +347,7 @@ async def test_stdio_server_list_resource_templates(run_context: RunContext[int] resource_templates = await server.list_resource_templates() assert len(resource_templates) == snapshot(1) - assert resource_templates[0].uriTemplate == snapshot('resource://greeting/{name}') + assert resource_templates[0].uri_template == snapshot('resource://greeting/{name}') assert resource_templates[0].name == snapshot('greeting_resource_template') assert resource_templates[0].description == snapshot('Dynamic greeting resource template.') From 67f9b077c616d2e12a733bdbf2e20c6b6b44f389 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 6 Oct 2025 22:30:06 +1100 Subject: [PATCH 09/32] Update MCPServer.read_resource() to decode/return native types. --- docs/mcp/client.md | 13 +++----- pydantic_ai_slim/pydantic_ai/mcp.py | 18 +++++------ tests/test_mcp.py | 47 +++++++++-------------------- 3 files changed, 27 insertions(+), 51 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index fcc1b6f510..b10d3ddaf1 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -328,7 +328,7 @@ Pydantic AI provides methods to discover and read resources from MCP servers: - [`list_resource_templates()`][pydantic_ai.mcp.MCPServer.list_resource_templates] - List resource templates with parameter placeholders - [`read_resource(uri)`][pydantic_ai.mcp.MCPServer.read_resource] - Read the contents of a specific resource by URI -Resources can contain either text content ([`TextResourceContents`][mcp.types.TextResourceContents]) or binary content ([`BlobResourceContents`][mcp.types.BlobResourceContents]) encoded as base64. +Resources are automatically converted: text content is returned as `str`, and binary content is returned as [`BinaryContent`][pydantic_ai.messages.BinaryContent]. Before consuming resources, we need to run a server that exposes some: @@ -353,8 +353,6 @@ Then we can create the client: ```python {title="mcp_resources.py", requires="mcp_resource_server.py"} import asyncio -from mcp.types import TextResourceContents - from pydantic_ai import Agent from pydantic_ai._run_context import RunContext from pydantic_ai.mcp import MCPServerStdio @@ -383,15 +381,12 @@ async def main(): #> - user_name_resource: resource://user_name.txt (text/plain) # Read a text resource - text_contents = await server.read_resource('resource://user_name.txt') - for content in text_contents: - if isinstance(content, TextResourceContents): - print(f'Text content from {content.uri}: {content.text.strip()}') - #> Text content from resource://user_name.txt: Alice + user_name = await server.read_resource('resource://user_name.txt') + print(f'Text content: {user_name}') + #> Text content: Alice # Use resources in dependencies async with agent: - user_name = text_contents[0].text _ = await agent.run('Can you help me with my product?', deps=user_name) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index b40fc59ce8..cde87f9298 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -320,18 +320,23 @@ async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: result = await self._client.list_resource_templates() return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] - async def read_resource(self, uri: str) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: + async def read_resource(self, uri: str) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: """Read the contents of a specific resource by URI. Args: uri: The URI of the resource to read. Returns: - A list of resource contents (either TextResourceContents or BlobResourceContents). + The resource contents. If the resource has a single content item, returns that item directly. + If the resource has multiple content items, returns a list of items. """ async with self: # Ensure server is running result = await self._client.read_resource(AnyUrl(uri)) - return result.contents + return ( + self._get_content(result.contents[0]) + if len(result.contents) == 1 + else [self._get_content(resource) for resource in result.contents] + ) async def __aenter__(self) -> Self: """Enter the MCP server context. @@ -427,12 +432,7 @@ async def _map_tool_result_part( resource = part.resource return self._get_content(resource) elif isinstance(part, mcp_types.ResourceLink): - resource_result: mcp_types.ReadResourceResult = await self._client.read_resource(part.uri) - return ( - self._get_content(resource_result.contents[0]) - if len(resource_result.contents) == 1 - else [self._get_content(resource) for resource in resource_result.contents] - ) + return await self.read_resource(str(part.uri)) else: assert_never(part) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 735082e6e1..8d4591697b 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -38,13 +38,11 @@ from mcp.client.session import ClientSession from mcp.shared.context import RequestContext from mcp.types import ( - BlobResourceContents, CreateMessageRequestParams, ElicitRequestParams, ElicitResult, ImageContent, TextContent, - TextResourceContents, ) from pydantic_ai._mcp import map_from_mcp_params, map_from_model_response @@ -1499,49 +1497,32 @@ async def test_elicitation_callback_not_set(run_context: RunContext[int]): async def test_read_text_resource(run_context: RunContext[int]): - """Test reading a text resource (TextResourceContents).""" + """Test reading a text resource (converted to string).""" server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) async with server: - contents = await server.read_resource('resource://product_name.txt') - assert len(contents) == snapshot(1) - - content = contents[0] - assert str(content.uri) == snapshot('resource://product_name.txt') - assert content.mimeType == snapshot('text/plain') - assert isinstance(content, TextResourceContents) - assert content.text == snapshot('Pydantic AI\n') + content = await server.read_resource('resource://product_name.txt') + assert isinstance(content, str) + assert content == snapshot('Pydantic AI\n') async def test_read_blob_resource(run_context: RunContext[int]): - """Test reading a binary resource (BlobResourceContents).""" + """Test reading a binary resource (converted to BinaryContent).""" server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) async with server: - contents = await server.read_resource('resource://kiwi.png') - assert len(contents) == snapshot(1) - - content = contents[0] - assert str(content.uri) == snapshot('resource://kiwi.png') - assert content.mimeType == snapshot('image/png') - assert isinstance(content, BlobResourceContents) - # blob should be base64 encoded string - assert isinstance(content.blob, str) - # Decode and verify it's PNG data (starts with PNG magic bytes) - decoded_data = base64.b64decode(content.blob) - assert decoded_data[:8] == b'\x89PNG\r\n\x1a\n' # PNG magic bytes + content = await server.read_resource('resource://kiwi.png') + assert isinstance(content, BinaryContent) + assert content.media_type == snapshot('image/png') + # Verify it's PNG data (starts with PNG magic bytes) + assert content.data[:8] == b'\x89PNG\r\n\x1a\n' # PNG magic bytes async def test_read_resource_template(run_context: RunContext[int]): - """Test reading a resource template with parameters (TextResourceContents).""" + """Test reading a resource template with parameters (converted to string).""" server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) async with server: - contents = await server.read_resource('resource://greeting/Alice') - assert len(contents) == snapshot(1) - - content = contents[0] - assert str(content.uri) == snapshot('resource://greeting/Alice') - assert content.mimeType == snapshot('text/plain') - assert isinstance(content, TextResourceContents) - assert content.text == snapshot('Hello, Alice!') + content = await server.read_resource('resource://greeting/Alice') + assert isinstance(content, str) + assert content == snapshot('Hello, Alice!') def test_load_mcp_servers(tmp_path: Path): From e6cb0864e821502c6dc9c554f92b445f775e349b Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 6 Oct 2025 23:12:23 +1100 Subject: [PATCH 10/32] Add native MCP ResourceAnnotations type. --- pydantic_ai_slim/pydantic_ai/_mcp.py | 31 ++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_mcp.py b/pydantic_ai_slim/pydantic_ai/_mcp.py index 9475b407c9..9dfef5f8aa 100644 --- a/pydantic_ai_slim/pydantic_ai/_mcp.py +++ b/pydantic_ai_slim/pydantic_ai/_mcp.py @@ -2,7 +2,9 @@ from abc import ABC from collections.abc import Sequence from dataclasses import dataclass -from typing import Any, Literal +from typing import Annotated, Any, Literal + +from pydantic import Field from . import _utils, exceptions, messages @@ -15,6 +17,19 @@ ) from _import_error +@dataclass(repr=False, kw_only=True) +class ResourceAnnotations: + """Additional properties describing MCP entities.""" + + audience: list[mcp_types.Role] | None = None + """Intended audience for this entity.""" + + priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None + """Priority level for this entity, ranging from 0.0 to 1.0.""" + + __repr__ = _utils.dataclasses_no_defaults_repr + + @dataclass(repr=False, kw_only=True) class BaseResource(ABC): """Base class for MCP resources.""" @@ -31,7 +46,7 @@ class BaseResource(ABC): mime_type: str | None = None """The MIME type of the resource, if known.""" - annotations: dict[str, Any] | None = None + annotations: ResourceAnnotations | None = None """Optional annotations for the resource.""" meta: dict[str, Any] | None = None @@ -178,7 +193,11 @@ def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> Resource: description=mcp_resource.description, mime_type=mcp_resource.mimeType, size=mcp_resource.size, - annotations=mcp_resource.annotations.model_dump() if mcp_resource.annotations else None, + annotations=( + ResourceAnnotations(audience=mcp_resource.annotations.audience, priority=mcp_resource.annotations.priority) + if mcp_resource.annotations + else None + ), meta=mcp_resource.meta, ) @@ -191,6 +210,10 @@ def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> title=mcp_template.title, description=mcp_template.description, mime_type=mcp_template.mimeType, - annotations=mcp_template.annotations.model_dump() if mcp_template.annotations else None, + annotations=( + ResourceAnnotations(audience=mcp_template.annotations.audience, priority=mcp_template.annotations.priority) + if mcp_template.annotations + else None + ), meta=mcp_template.meta, ) From b8424d80eeeb46aef3ad8ae49ae29b1ef820c7af Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 6 Oct 2025 23:34:40 +1100 Subject: [PATCH 11/32] Allow MCPServer.read_resource() to read resources by Resource. --- pydantic_ai_slim/pydantic_ai/mcp.py | 19 +++++++++++++++---- tests/test_mcp.py | 8 ++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index cde87f9298..7b237a34a3 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -10,7 +10,7 @@ from dataclasses import field, replace from datetime import timedelta from pathlib import Path -from typing import Annotated, Any +from typing import Annotated, Any, overload import anyio import httpx @@ -320,18 +320,29 @@ async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: result = await self._client.list_resource_templates() return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] - async def read_resource(self, uri: str) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: + @overload + async def read_resource(self, uri: str) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ... + + @overload + async def read_resource( + self, uri: _mcp.Resource + ) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ... + + async def read_resource( + self, uri: str | _mcp.Resource + ) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: """Read the contents of a specific resource by URI. Args: - uri: The URI of the resource to read. + uri: The URI of the resource to read, or a Resource object. Returns: The resource contents. If the resource has a single content item, returns that item directly. If the resource has multiple content items, returns a list of items. """ + resource_uri = uri if isinstance(uri, str) else uri.uri async with self: # Ensure server is running - result = await self._client.read_resource(AnyUrl(uri)) + result = await self._client.read_resource(AnyUrl(resource_uri)) return ( self._get_content(result.contents[0]) if len(result.contents) == 1 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 8d4591697b..b43ca94aa5 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -23,6 +23,7 @@ ToolReturnPart, UserPromptPart, ) +from pydantic_ai._mcp import Resource from pydantic_ai.agent import Agent from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior, UserError from pydantic_ai.mcp import MCPServerStreamableHTTP, load_mcp_servers @@ -1500,10 +1501,17 @@ async def test_read_text_resource(run_context: RunContext[int]): """Test reading a text resource (converted to string).""" server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) async with server: + # Test reading by URI string content = await server.read_resource('resource://product_name.txt') assert isinstance(content, str) assert content == snapshot('Pydantic AI\n') + # Test reading by Resource object + resource = Resource(uri='resource://product_name.txt', name='product_name_resource') + content_from_resource = await server.read_resource(resource) + assert isinstance(content_from_resource, str) + assert content_from_resource == snapshot('Pydantic AI\n') + async def test_read_blob_resource(run_context: RunContext[int]): """Test reading a binary resource (converted to BinaryContent).""" From 6857ef258ccb3c816a6f651a531df94064f03969 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 10:45:47 +1100 Subject: [PATCH 12/32] Bump dependency mcp>=1.18.0 --- pydantic_ai_slim/pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index 1a0c627c8f..d97ed92ac7 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -87,7 +87,7 @@ cli = [ "pyperclip>=1.9.0", ] # MCP -mcp = ["mcp>=1.12.3"] +mcp = ["mcp>=1.18.0"] # Evals evals = ["pydantic-evals=={{ version }}"] # A2A diff --git a/uv.lock b/uv.lock index b3d9d80695..b6b839f1a0 100644 --- a/uv.lock +++ b/uv.lock @@ -2279,7 +2279,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.13.1" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2294,9 +2294,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/3c/82c400c2d50afdac4fbefb5b4031fd327e2ad1f23ccef8eee13c5909aa48/mcp-1.13.1.tar.gz", hash = "sha256:165306a8fd7991dc80334edd2de07798175a56461043b7ae907b279794a834c5", size = 438198, upload-time = "2025-08-22T09:22:16.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/3f/d085c7f49ade6d273b185d61ec9405e672b6433f710ea64a90135a8dd445/mcp-1.13.1-py3-none-any.whl", hash = "sha256:c314e7c8bd477a23ba3ef472ee5a32880316c42d03e06dcfa31a1cc7a73b65df", size = 161494, upload-time = "2025-08-22T09:22:14.705Z" }, + { url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" }, ] [package.optional-dependencies] @@ -3907,7 +3907,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "huggingface-hub", extras = ["inference"], marker = "extra == 'huggingface'", specifier = ">=0.33.5" }, { name = "logfire", extras = ["httpx"], marker = "extra == 'logfire'", specifier = ">=3.14.1" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.12.3" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.18.0" }, { name = "mistralai", marker = "extra == 'mistral'", specifier = ">=1.9.10" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.107.2" }, { name = "opentelemetry-api", specifier = ">=1.28.0" }, From 18743cde32a324eb7cc9d463f5212601377a34ac Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 10:57:12 +1100 Subject: [PATCH 13/32] Add test coverage for ResourceAnnotations now that we can. --- tests/mcp_server.py | 7 ++++++- tests/test_mcp.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/mcp_server.py b/tests/mcp_server.py index 13ef2410c2..919f21ce68 100644 --- a/tests/mcp_server.py +++ b/tests/mcp_server.py @@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP, Image from mcp.server.session import ServerSession from mcp.types import ( + Annotations, BlobResourceContents, CreateMessageResult, EmbeddedResource, @@ -120,7 +121,11 @@ async def get_product_name_link() -> ResourceLink: ) -@mcp.resource('resource://product_name.txt', mime_type='text/plain') +@mcp.resource( + 'resource://product_name.txt', + mime_type='text/plain', + annotations=Annotations(audience=['user', 'assistant'], priority=0.5), +) async def product_name_resource() -> str: return Path(__file__).parent.joinpath('assets/product_name.txt').read_text() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b43ca94aa5..393642a274 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -330,14 +330,20 @@ async def test_stdio_server_list_resources(run_context: RunContext[int]): assert resources[0].uri == snapshot('resource://kiwi.png') assert resources[0].mime_type == snapshot('image/png') assert resources[0].name == snapshot('kiwi_resource') + assert resources[0].annotations is None assert resources[1].uri == snapshot('resource://marcelo.mp3') assert resources[1].mime_type == snapshot('audio/mpeg') assert resources[1].name == snapshot('marcelo_resource') + assert resources[1].annotations is None assert resources[2].uri == snapshot('resource://product_name.txt') assert resources[2].mime_type == snapshot('text/plain') assert resources[2].name == snapshot('product_name_resource') + # Test ResourceAnnotations + assert resources[2].annotations is not None + assert resources[2].annotations.audience == snapshot(['user', 'assistant']) + assert resources[2].annotations.priority == snapshot(0.5) async def test_stdio_server_list_resource_templates(run_context: RunContext[int]): From 4e5d6270b507f83e8a1abad80bfa1277d4933561 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 11:25:53 +1100 Subject: [PATCH 14/32] Add MCPServer.capabilities property. --- pydantic_ai_slim/pydantic_ai/_mcp.py | 37 ++++++++++++++++++++++++++++ pydantic_ai_slim/pydantic_ai/mcp.py | 11 +++++++++ tests/test_mcp.py | 15 +++++++++++ 3 files changed, 63 insertions(+) diff --git a/pydantic_ai_slim/pydantic_ai/_mcp.py b/pydantic_ai_slim/pydantic_ai/_mcp.py index 9dfef5f8aa..a4000044ac 100644 --- a/pydantic_ai_slim/pydantic_ai/_mcp.py +++ b/pydantic_ai_slim/pydantic_ai/_mcp.py @@ -74,6 +74,31 @@ class ResourceTemplate(BaseResource): """URI template (RFC 6570) for constructing resource URIs.""" +@dataclass(repr=False, kw_only=True) +class ServerCapabilities: + """Capabilities that an MCP server supports.""" + + experimental: list[str] | None = None + """Experimental, non-standard capabilities that the server supports.""" + + logging: bool = False + """Whether the server supports sending log messages to the client.""" + + prompts: bool = False + """Whether the server offers any prompt templates.""" + + resources: bool = False + """Whether the server offers any resources to read.""" + + tools: bool = False + """Whether the server offers any tools to call.""" + + completions: bool = False + """Whether the server offers autocompletion suggestions for prompts and resources.""" + + __repr__ = _utils.dataclasses_no_defaults_repr + + def map_from_mcp_params(params: mcp_types.CreateMessageRequestParams) -> list[messages.ModelMessage]: """Convert from MCP create message request parameters to pydantic-ai messages.""" pai_messages: list[messages.ModelMessage] = [] @@ -217,3 +242,15 @@ def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> ), meta=mcp_template.meta, ) + + +def map_from_mcp_server_capabilities(mcp_capabilities: mcp_types.ServerCapabilities) -> ServerCapabilities: + """Convert from MCP ServerCapabilities to native Pydantic AI ServerCapabilities.""" + return ServerCapabilities( + experimental=list(mcp_capabilities.experimental.keys()) if mcp_capabilities.experimental else None, + logging=mcp_capabilities.logging is not None, + prompts=mcp_capabilities.prompts is not None, + resources=mcp_capabilities.resources is not None, + tools=mcp_capabilities.tools is not None, + completions=mcp_capabilities.completions is not None, + ) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 7b237a34a3..d1b42f9538 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -113,6 +113,7 @@ class MCPServer(AbstractToolset[Any], ABC): _read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] _write_stream: MemoryObjectSendStream[SessionMessage] _server_info: mcp_types.Implementation + _server_capabilities: _mcp.ServerCapabilities def __init__( self, @@ -191,6 +192,15 @@ def server_info(self) -> mcp_types.Implementation: ) return self._server_info + @property + def capabilities(self) -> _mcp.ServerCapabilities: + """Access the capabilities advertised by the MCP server during initialization.""" + if getattr(self, '_server_capabilities', None) is None: + raise AttributeError( + f'The `{self.__class__.__name__}.capabilities` is only instantiated after initialization.' + ) + return self._server_capabilities + async def list_tools(self) -> list[mcp_types.Tool]: """Retrieve tools that are currently active on the server. @@ -374,6 +384,7 @@ async def __aenter__(self) -> Self: with anyio.fail_after(self.timeout): result = await self._client.initialize() self._server_info = result.serverInfo + self._server_capabilities = _mcp.map_from_mcp_server_capabilities(result.capabilities) if log_level := self.log_level: await self._client.set_logging_level(log_level) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 393642a274..e135f800fa 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1568,3 +1568,18 @@ async def test_server_info(mcp_server: MCPServerStdio) -> None: async with mcp_server: assert mcp_server.server_info is not None assert mcp_server.server_info.name == 'Pydantic AI MCP Server' + + +async def test_capabilities(mcp_server: MCPServerStdio) -> None: + with pytest.raises( + AttributeError, match='The `MCPServerStdio.capabilities` is only instantiated after initialization.' + ): + mcp_server.capabilities + async with mcp_server: + assert mcp_server.capabilities is not None + assert mcp_server.capabilities.resources is True + assert mcp_server.capabilities.tools is True + assert mcp_server.capabilities.prompts is True + assert mcp_server.capabilities.logging is True + assert mcp_server.capabilities.completions is False + assert mcp_server.capabilities.experimental is None From 6f3a87a63ab68bbe7eca8cbbbc2280cd6747afb5 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 11:47:27 +1100 Subject: [PATCH 15/32] Introduce native MCPError type and use it in MCP server resource methods. --- pydantic_ai_slim/pydantic_ai/exceptions.py | 20 ++++++++++++++++++ pydantic_ai_slim/pydantic_ai/mcp.py | 24 +++++++++++++++++++++- tests/test_mcp.py | 20 ++++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/exceptions.py b/pydantic_ai_slim/pydantic_ai/exceptions.py index 58a7686e06..ba80c9a7eb 100644 --- a/pydantic_ai_slim/pydantic_ai/exceptions.py +++ b/pydantic_ai_slim/pydantic_ai/exceptions.py @@ -23,6 +23,8 @@ 'UnexpectedModelBehavior', 'UsageLimitExceeded', 'ModelHTTPError', + 'MCPError', + 'ServerCapabilitiesError', 'FallbackExceptionGroup', ) @@ -158,6 +160,24 @@ def __init__(self, status_code: int, model_name: str, body: object | None = None super().__init__(message) +class MCPError(RuntimeError): + """Base class for errors occurring during interaction with an MCP server.""" + + message: str + """The error message.""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + def __str__(self) -> str: + return self.message + + +class ServerCapabilitiesError(MCPError): + """Raised when attempting to access server capabilities that aren't present.""" + + class FallbackExceptionGroup(ExceptionGroup): """A group of exceptions that can be raised when all fallback models fail.""" diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index d1b42f9538..9c8a077b77 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -319,14 +319,29 @@ async def list_resources(self) -> list[_mcp.Resource]: Note: - We don't cache resources as they might change. - We also don't subscribe to resource changes to avoid complexity. + + Raises: + ServerCapabilitiesError: If the server does not support resources. """ async with self: # Ensure server is running + if not self.capabilities.resources: + raise exceptions.ServerCapabilitiesError( + f'Server does not support resources capability. Available capabilities: {self.capabilities}' + ) result = await self._client.list_resources() return [_mcp.map_from_mcp_resource(r) for r in result.resources] async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: - """Retrieve resource templates that are currently present on the server.""" + """Retrieve resource templates that are currently present on the server. + + Raises: + ServerCapabilitiesError: If the server does not support resources. + """ async with self: # Ensure server is running + if not self.capabilities.resources: + raise exceptions.ServerCapabilitiesError( + f'Server does not support resources capability. Available capabilities: {self.capabilities}' + ) result = await self._client.list_resource_templates() return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] @@ -349,9 +364,16 @@ async def read_resource( Returns: The resource contents. If the resource has a single content item, returns that item directly. If the resource has multiple content items, returns a list of items. + + Raises: + ServerCapabilitiesError: If the server does not support resources. """ resource_uri = uri if isinstance(uri, str) else uri.uri async with self: # Ensure server is running + if not self.capabilities.resources: + raise exceptions.ServerCapabilitiesError( + f'Server does not support resources capability. Available capabilities: {self.capabilities}' + ) result = await self._client.read_resource(AnyUrl(resource_uri)) return ( self._get_content(result.contents[0]) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index e135f800fa..57778752c5 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -23,9 +23,9 @@ ToolReturnPart, UserPromptPart, ) -from pydantic_ai._mcp import Resource +from pydantic_ai._mcp import Resource, ServerCapabilities from pydantic_ai.agent import Agent -from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior, UserError +from pydantic_ai.exceptions import ModelRetry, ServerCapabilitiesError, UnexpectedModelBehavior, UserError from pydantic_ai.mcp import MCPServerStreamableHTTP, load_mcp_servers from pydantic_ai.models import Model from pydantic_ai.models.test import TestModel @@ -1583,3 +1583,19 @@ async def test_capabilities(mcp_server: MCPServerStdio) -> None: assert mcp_server.capabilities.logging is True assert mcp_server.capabilities.completions is False assert mcp_server.capabilities.experimental is None + + +async def test_resource_methods_without_capability(mcp_server: MCPServerStdio) -> None: + """Test that resource methods raise ServerCapabilitiesError when resources capability is not available.""" + async with mcp_server: + # Mock the capabilities to not support resources + mock_capabilities = ServerCapabilities(resources=False) + with patch.object(mcp_server, '_server_capabilities', mock_capabilities): + with pytest.raises(ServerCapabilitiesError, match='Server does not support resources capability'): + await mcp_server.list_resources() + + with pytest.raises(ServerCapabilitiesError, match='Server does not support resources capability'): + await mcp_server.list_resource_templates() + + with pytest.raises(ServerCapabilitiesError, match='Server does not support resources capability'): + await mcp_server.read_resource('resource://test') From 72d66ac725c4950d7c23f0229816215e2f52e30d Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 18:55:11 +1100 Subject: [PATCH 16/32] Simplify error messages. --- pydantic_ai_slim/pydantic_ai/mcp.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 9c8a077b77..ec7ca7a0f5 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -325,9 +325,7 @@ async def list_resources(self) -> list[_mcp.Resource]: """ async with self: # Ensure server is running if not self.capabilities.resources: - raise exceptions.ServerCapabilitiesError( - f'Server does not support resources capability. Available capabilities: {self.capabilities}' - ) + raise exceptions.ServerCapabilitiesError('Server does not support resources capability') result = await self._client.list_resources() return [_mcp.map_from_mcp_resource(r) for r in result.resources] @@ -339,9 +337,7 @@ async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: """ async with self: # Ensure server is running if not self.capabilities.resources: - raise exceptions.ServerCapabilitiesError( - f'Server does not support resources capability. Available capabilities: {self.capabilities}' - ) + raise exceptions.ServerCapabilitiesError('Server does not support resources capability') result = await self._client.list_resource_templates() return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] @@ -371,9 +367,7 @@ async def read_resource( resource_uri = uri if isinstance(uri, str) else uri.uri async with self: # Ensure server is running if not self.capabilities.resources: - raise exceptions.ServerCapabilitiesError( - f'Server does not support resources capability. Available capabilities: {self.capabilities}' - ) + raise exceptions.ServerCapabilitiesError('Server does not support resources capability') result = await self._client.read_resource(AnyUrl(resource_uri)) return ( self._get_content(result.contents[0]) From 3000511bf6abe65ce7ab6f45a56df0af4a27fd06 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 19:14:36 +1100 Subject: [PATCH 17/32] Add MCPServerError and use it appropriately upon upstream resource errors. --- pydantic_ai_slim/pydantic_ai/exceptions.py | 39 ++++++++++++++++++++++ pydantic_ai_slim/pydantic_ai/mcp.py | 18 ++++++++-- tests/test_mcp.py | 19 ++++++++++- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/exceptions.py b/pydantic_ai_slim/pydantic_ai/exceptions.py index ba80c9a7eb..8f1fa4e219 100644 --- a/pydantic_ai_slim/pydantic_ai/exceptions.py +++ b/pydantic_ai_slim/pydantic_ai/exceptions.py @@ -25,6 +25,7 @@ 'ModelHTTPError', 'MCPError', 'ServerCapabilitiesError', + 'MCPServerError', 'FallbackExceptionGroup', ) @@ -178,6 +179,44 @@ class ServerCapabilitiesError(MCPError): """Raised when attempting to access server capabilities that aren't present.""" +class MCPServerError(MCPError): + """Raised when an MCP server returns an error response. + + This exception wraps error responses from MCP servers, following the ErrorData schema + from the MCP specification. + """ + + code: int + """The error code returned by the server.""" + + data: Any | None + """Additional information about the error, if provided by the server.""" + + def __init__(self, message: str, code: int, data: Any | None = None): + super().__init__(message) + self.code = code + self.data = data + + @classmethod + def from_mcp_sdk_error(cls, error: Any) -> MCPServerError: + """Create an MCPServerError from an MCP SDK McpError. + + Args: + error: An McpError from the MCP SDK. + + Returns: + A new MCPServerError instance with the error data. + """ + # Extract error data from the McpError.error attribute + error_data = error.error + return cls(message=error_data.message, code=error_data.code, data=error_data.data) + + def __str__(self) -> str: + if self.data: + return f'{self.message} (code: {self.code}, data: {self.data})' + return f'{self.message} (code: {self.code})' + + class FallbackExceptionGroup(ExceptionGroup): """A group of exceptions that can be raised when all fallback models fail.""" diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index ec7ca7a0f5..d04e2093e3 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -322,11 +322,15 @@ async def list_resources(self) -> list[_mcp.Resource]: Raises: ServerCapabilitiesError: If the server does not support resources. + MCPServerError: If the server returns an error. """ async with self: # Ensure server is running if not self.capabilities.resources: raise exceptions.ServerCapabilitiesError('Server does not support resources capability') - result = await self._client.list_resources() + try: + result = await self._client.list_resources() + except McpError as e: + raise exceptions.MCPServerError.from_mcp_sdk_error(e) from e return [_mcp.map_from_mcp_resource(r) for r in result.resources] async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: @@ -334,11 +338,15 @@ async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: Raises: ServerCapabilitiesError: If the server does not support resources. + MCPServerError: If the server returns an error. """ async with self: # Ensure server is running if not self.capabilities.resources: raise exceptions.ServerCapabilitiesError('Server does not support resources capability') - result = await self._client.list_resource_templates() + try: + result = await self._client.list_resource_templates() + except McpError as e: + raise exceptions.MCPServerError.from_mcp_sdk_error(e) from e return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] @overload @@ -363,12 +371,16 @@ async def read_resource( Raises: ServerCapabilitiesError: If the server does not support resources. + MCPServerError: If the server returns an error (e.g., resource not found). """ resource_uri = uri if isinstance(uri, str) else uri.uri async with self: # Ensure server is running if not self.capabilities.resources: raise exceptions.ServerCapabilitiesError('Server does not support resources capability') - result = await self._client.read_resource(AnyUrl(resource_uri)) + try: + result = await self._client.read_resource(AnyUrl(resource_uri)) + except McpError as e: + raise exceptions.MCPServerError.from_mcp_sdk_error(e) from e return ( self._get_content(result.contents[0]) if len(result.contents) == 1 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 57778752c5..b15a22cbc2 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -25,7 +25,13 @@ ) from pydantic_ai._mcp import Resource, ServerCapabilities from pydantic_ai.agent import Agent -from pydantic_ai.exceptions import ModelRetry, ServerCapabilitiesError, UnexpectedModelBehavior, UserError +from pydantic_ai.exceptions import ( + MCPServerError, + ModelRetry, + ServerCapabilitiesError, + UnexpectedModelBehavior, + UserError, +) from pydantic_ai.mcp import MCPServerStreamableHTTP, load_mcp_servers from pydantic_ai.models import Model from pydantic_ai.models.test import TestModel @@ -1539,6 +1545,17 @@ async def test_read_resource_template(run_context: RunContext[int]): assert content == snapshot('Hello, Alice!') +async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None: + """Test that read_resource raises MCPServerError for non-existent resources.""" + async with mcp_server: + with pytest.raises(MCPServerError, match='Unknown resource: resource://does_not_exist') as exc_info: + await mcp_server.read_resource('resource://does_not_exist') + + # Verify the exception has the expected attributes + assert exc_info.value.code == 0 + assert exc_info.value.message == 'Unknown resource: resource://does_not_exist' + + def test_load_mcp_servers(tmp_path: Path): config = tmp_path / 'mcp.json' From 30f4e7f15742d4da43fdb32754570411fa7c31a3 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 19:26:11 +1100 Subject: [PATCH 18/32] Cleanup MCP error naming and usage. --- pydantic_ai_slim/pydantic_ai/exceptions.py | 4 +-- pydantic_ai_slim/pydantic_ai/mcp.py | 29 +++++++++++----------- tests/test_mcp.py | 10 ++++---- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/exceptions.py b/pydantic_ai_slim/pydantic_ai/exceptions.py index 8f1fa4e219..fba7484334 100644 --- a/pydantic_ai_slim/pydantic_ai/exceptions.py +++ b/pydantic_ai_slim/pydantic_ai/exceptions.py @@ -24,7 +24,7 @@ 'UsageLimitExceeded', 'ModelHTTPError', 'MCPError', - 'ServerCapabilitiesError', + 'MCPServerCapabilitiesError', 'MCPServerError', 'FallbackExceptionGroup', ) @@ -175,7 +175,7 @@ def __str__(self) -> str: return self.message -class ServerCapabilitiesError(MCPError): +class MCPServerCapabilitiesError(MCPError): """Raised when attempting to access server capabilities that aren't present.""" diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index d04e2093e3..2899d65705 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -31,8 +31,8 @@ from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.client.streamable_http import GetSessionIdCallback, streamablehttp_client + from mcp.shared import exceptions as mcp_exceptions from mcp.shared.context import RequestContext - from mcp.shared.exceptions import McpError from mcp.shared.message import SessionMessage except ImportError as _import_error: raise ImportError( @@ -42,6 +42,7 @@ # after mcp imports so any import error maps to this file, not _mcp.py from . import _mcp, _utils, exceptions, messages, models +from .exceptions import MCPServerCapabilitiesError, MCPServerError __all__ = 'MCPServer', 'MCPServerStdio', 'MCPServerHTTP', 'MCPServerSSE', 'MCPServerStreamableHTTP', 'load_mcp_servers' @@ -246,7 +247,7 @@ async def direct_call_tool( ), mcp_types.CallToolResult, ) - except McpError as e: + except mcp_exceptions.McpError as e: raise exceptions.ModelRetry(e.error.message) if result.isError: @@ -321,32 +322,32 @@ async def list_resources(self) -> list[_mcp.Resource]: - We also don't subscribe to resource changes to avoid complexity. Raises: - ServerCapabilitiesError: If the server does not support resources. + MCPServerCapabilitiesError: If the server does not support resources. MCPServerError: If the server returns an error. """ async with self: # Ensure server is running if not self.capabilities.resources: - raise exceptions.ServerCapabilitiesError('Server does not support resources capability') + raise MCPServerCapabilitiesError('Server does not support resources capability') try: result = await self._client.list_resources() - except McpError as e: - raise exceptions.MCPServerError.from_mcp_sdk_error(e) from e + except mcp_exceptions.McpError as e: + raise MCPServerError.from_mcp_sdk_error(e) from e return [_mcp.map_from_mcp_resource(r) for r in result.resources] async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: """Retrieve resource templates that are currently present on the server. Raises: - ServerCapabilitiesError: If the server does not support resources. + MCPServerCapabilitiesError: If the server does not support resources. MCPServerError: If the server returns an error. """ async with self: # Ensure server is running if not self.capabilities.resources: - raise exceptions.ServerCapabilitiesError('Server does not support resources capability') + raise MCPServerCapabilitiesError('Server does not support resources capability') try: result = await self._client.list_resource_templates() - except McpError as e: - raise exceptions.MCPServerError.from_mcp_sdk_error(e) from e + except mcp_exceptions.McpError as e: + raise MCPServerError.from_mcp_sdk_error(e) from e return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] @overload @@ -370,17 +371,17 @@ async def read_resource( If the resource has multiple content items, returns a list of items. Raises: - ServerCapabilitiesError: If the server does not support resources. + MCPServerCapabilitiesError: If the server does not support resources. MCPServerError: If the server returns an error (e.g., resource not found). """ resource_uri = uri if isinstance(uri, str) else uri.uri async with self: # Ensure server is running if not self.capabilities.resources: - raise exceptions.ServerCapabilitiesError('Server does not support resources capability') + raise MCPServerCapabilitiesError('Server does not support resources capability') try: result = await self._client.read_resource(AnyUrl(resource_uri)) - except McpError as e: - raise exceptions.MCPServerError.from_mcp_sdk_error(e) from e + except mcp_exceptions.McpError as e: + raise MCPServerError.from_mcp_sdk_error(e) from e return ( self._get_content(result.contents[0]) if len(result.contents) == 1 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b15a22cbc2..56152af5e8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -26,9 +26,9 @@ from pydantic_ai._mcp import Resource, ServerCapabilities from pydantic_ai.agent import Agent from pydantic_ai.exceptions import ( + MCPServerCapabilitiesError, MCPServerError, ModelRetry, - ServerCapabilitiesError, UnexpectedModelBehavior, UserError, ) @@ -1603,16 +1603,16 @@ async def test_capabilities(mcp_server: MCPServerStdio) -> None: async def test_resource_methods_without_capability(mcp_server: MCPServerStdio) -> None: - """Test that resource methods raise ServerCapabilitiesError when resources capability is not available.""" + """Test that resource methods raise MCPServerCapabilitiesError when resources capability is not available.""" async with mcp_server: # Mock the capabilities to not support resources mock_capabilities = ServerCapabilities(resources=False) with patch.object(mcp_server, '_server_capabilities', mock_capabilities): - with pytest.raises(ServerCapabilitiesError, match='Server does not support resources capability'): + with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'): await mcp_server.list_resources() - with pytest.raises(ServerCapabilitiesError, match='Server does not support resources capability'): + with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'): await mcp_server.list_resource_templates() - with pytest.raises(ServerCapabilitiesError, match='Server does not support resources capability'): + with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'): await mcp_server.read_resource('resource://test') From 456e714fac53a862a17f3d251a03c8daa6e0b694 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 20:02:09 +1100 Subject: [PATCH 19/32] Increase MCP server test coverage. --- tests/test_mcp.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 56152af5e8..f33b93d453 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1556,6 +1556,42 @@ async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None: assert exc_info.value.message == 'Unknown resource: resource://does_not_exist' +async def test_list_resources_error(mcp_server: MCPServerStdio) -> None: + """Test that list_resources converts McpError to MCPServerError.""" + mcp_error = McpError(error=ErrorData(code=-32603, message='Failed to list resources')) + + async with mcp_server: + with patch.object( + mcp_server._client, # pyright: ignore[reportPrivateUsage] + 'list_resources', + new=AsyncMock(side_effect=mcp_error), + ): + with pytest.raises(MCPServerError, match='Failed to list resources') as exc_info: + await mcp_server.list_resources() + + # Verify the exception has the expected attributes + assert exc_info.value.code == -32603 + assert exc_info.value.message == 'Failed to list resources' + + +async def test_list_resource_templates_error(mcp_server: MCPServerStdio) -> None: + """Test that list_resource_templates converts McpError to MCPServerError.""" + mcp_error = McpError(error=ErrorData(code=-32001, message='Service unavailable')) + + async with mcp_server: + with patch.object( + mcp_server._client, # pyright: ignore[reportPrivateUsage] + 'list_resource_templates', + new=AsyncMock(side_effect=mcp_error), + ): + with pytest.raises(MCPServerError, match='Service unavailable') as exc_info: + await mcp_server.list_resource_templates() + + # Verify the exception has the expected attributes + assert exc_info.value.code == -32001 + assert exc_info.value.message == 'Service unavailable' + + def test_load_mcp_servers(tmp_path: Path): config = tmp_path / 'mcp.json' From 3d95521289b2d3f00fe075c9253b619ecbe04ee6 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sun, 19 Oct 2025 20:28:53 +1100 Subject: [PATCH 20/32] Fix test coverage gap. --- tests/test_mcp.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index f33b93d453..33f85fa73c 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1558,7 +1558,9 @@ async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None: async def test_list_resources_error(mcp_server: MCPServerStdio) -> None: """Test that list_resources converts McpError to MCPServerError.""" - mcp_error = McpError(error=ErrorData(code=-32603, message='Failed to list resources')) + mcp_error = McpError( + error=ErrorData(code=-32603, message='Failed to list resources', data={'details': 'server overloaded'}) + ) async with mcp_server: with patch.object( @@ -1572,6 +1574,10 @@ async def test_list_resources_error(mcp_server: MCPServerStdio) -> None: # Verify the exception has the expected attributes assert exc_info.value.code == -32603 assert exc_info.value.message == 'Failed to list resources' + assert exc_info.value.data == {'details': 'server overloaded'} + assert ( + str(exc_info.value) == "Failed to list resources (code: -32603, data: {'details': 'server overloaded'})" + ) async def test_list_resource_templates_error(mcp_server: MCPServerStdio) -> None: From 28cf76124622ec095ceeab2dd9d8b09e22b72635 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sat, 1 Nov 2025 17:04:20 +1100 Subject: [PATCH 21/32] Return [] | None on MCP resource methods when not found or no capabilities. --- pydantic_ai_slim/pydantic_ai/exceptions.py | 5 -- pydantic_ai_slim/pydantic_ai/mcp.py | 33 +++++++++----- tests/test_mcp.py | 53 ++++++++++++++++++---- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/exceptions.py b/pydantic_ai_slim/pydantic_ai/exceptions.py index 8aa8da5dc1..be9bb4ed97 100644 --- a/pydantic_ai_slim/pydantic_ai/exceptions.py +++ b/pydantic_ai_slim/pydantic_ai/exceptions.py @@ -24,7 +24,6 @@ 'UsageLimitExceeded', 'ModelHTTPError', 'MCPError', - 'MCPServerCapabilitiesError', 'MCPServerError', 'IncompleteToolCall', 'FallbackExceptionGroup', @@ -176,10 +175,6 @@ def __str__(self) -> str: return self.message -class MCPServerCapabilitiesError(MCPError): - """Raised when attempting to access server capabilities that aren't present.""" - - class MCPServerError(MCPError): """Raised when an MCP server returns an error response. diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 2a37809437..fc521e3804 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -42,7 +42,7 @@ # after mcp imports so any import error maps to this file, not _mcp.py from . import _mcp, _utils, exceptions, messages, models -from .exceptions import MCPServerCapabilitiesError, MCPServerError +from .exceptions import MCPServerError __all__ = 'MCPServer', 'MCPServerStdio', 'MCPServerHTTP', 'MCPServerSSE', 'MCPServerStreamableHTTP', 'load_mcp_servers' @@ -322,12 +322,11 @@ async def list_resources(self) -> list[_mcp.Resource]: - We also don't subscribe to resource changes to avoid complexity. Raises: - MCPServerCapabilitiesError: If the server does not support resources. MCPServerError: If the server returns an error. """ async with self: # Ensure server is running if not self.capabilities.resources: - raise MCPServerCapabilitiesError('Server does not support resources capability') + return [] try: result = await self._client.list_resources() except mcp_exceptions.McpError as e: @@ -338,12 +337,11 @@ async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: """Retrieve resource templates that are currently present on the server. Raises: - MCPServerCapabilitiesError: If the server does not support resources. MCPServerError: If the server returns an error. """ async with self: # Ensure server is running if not self.capabilities.resources: - raise MCPServerCapabilitiesError('Server does not support resources capability') + return [] try: result = await self._client.list_resource_templates() except mcp_exceptions.McpError as e: @@ -351,16 +349,18 @@ async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] @overload - async def read_resource(self, uri: str) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ... + async def read_resource( + self, uri: str + ) -> str | messages.BinaryContent | list[str | messages.BinaryContent] | None: ... @overload async def read_resource( self, uri: _mcp.Resource - ) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ... + ) -> str | messages.BinaryContent | list[str | messages.BinaryContent] | None: ... async def read_resource( self, uri: str | _mcp.Resource - ) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: + ) -> str | messages.BinaryContent | list[str | messages.BinaryContent] | None: """Read the contents of a specific resource by URI. Args: @@ -369,19 +369,26 @@ async def read_resource( Returns: The resource contents. If the resource has a single content item, returns that item directly. If the resource has multiple content items, returns a list of items. + Returns `None` if the server does not support resources or the resource is not found. Raises: - MCPServerCapabilitiesError: If the server does not support resources. - MCPServerError: If the server returns an error (e.g., resource not found). + MCPServerError: If the server returns an error other than resource not found. """ resource_uri = uri if isinstance(uri, str) else uri.uri async with self: # Ensure server is running if not self.capabilities.resources: - raise MCPServerCapabilitiesError('Server does not support resources capability') + return None try: result = await self._client.read_resource(AnyUrl(resource_uri)) except mcp_exceptions.McpError as e: + # As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling + if e.error.code == -32002: + return None raise MCPServerError.from_mcp_sdk_error(e) from e + + if not result.contents: + return None + return ( self._get_content(result.contents[0]) if len(result.contents) == 1 @@ -483,7 +490,9 @@ async def _map_tool_result_part( resource = part.resource return self._get_content(resource) elif isinstance(part, mcp_types.ResourceLink): - return await self.read_resource(str(part.uri)) + result = await self.read_resource(str(part.uri)) + # If resource not found, return an empty string as it's impossible to fetch anyway + return result if result is not None else '' else: assert_never(part) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 33f85fa73c..deaf4a6422 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -26,7 +26,6 @@ from pydantic_ai._mcp import Resource, ServerCapabilities from pydantic_ai.agent import Agent from pydantic_ai.exceptions import ( - MCPServerCapabilitiesError, MCPServerError, ModelRetry, UnexpectedModelBehavior, @@ -1546,8 +1545,9 @@ async def test_read_resource_template(run_context: RunContext[int]): async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None: - """Test that read_resource raises MCPServerError for non-existent resources.""" + """Test that read_resource raises MCPServerError for non-existent resources with non-standard error codes.""" async with mcp_server: + # FastMCP uses error code 0 instead of -32002, so it should raise with pytest.raises(MCPServerError, match='Unknown resource: resource://does_not_exist') as exc_info: await mcp_server.read_resource('resource://does_not_exist') @@ -1556,6 +1556,38 @@ async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None: assert exc_info.value.message == 'Unknown resource: resource://does_not_exist' +async def test_read_resource_not_found_mcp_spec(mcp_server: MCPServerStdio) -> None: + """Test that read_resource returns None for MCP spec error code -32002 (resource not found).""" + # As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling + mcp_error = McpError(error=ErrorData(code=-32002, message='Resource not found')) + + async with mcp_server: + with patch.object( + mcp_server._client, # pyright: ignore[reportPrivateUsage] + 'read_resource', + new=AsyncMock(side_effect=mcp_error), + ): + result = await mcp_server.read_resource('resource://missing') + assert result is None + + +async def test_read_resource_empty_contents(mcp_server: MCPServerStdio) -> None: + """Test that read_resource returns None when server returns empty contents.""" + from mcp.types import ReadResourceResult + + # Mock a result with empty contents + empty_result = ReadResourceResult(contents=[]) + + async with mcp_server: + with patch.object( + mcp_server._client, # pyright: ignore[reportPrivateUsage] + 'read_resource', + new=AsyncMock(return_value=empty_result), + ): + result = await mcp_server.read_resource('resource://empty') + assert result is None + + async def test_list_resources_error(mcp_server: MCPServerStdio) -> None: """Test that list_resources converts McpError to MCPServerError.""" mcp_error = McpError( @@ -1645,16 +1677,19 @@ async def test_capabilities(mcp_server: MCPServerStdio) -> None: async def test_resource_methods_without_capability(mcp_server: MCPServerStdio) -> None: - """Test that resource methods raise MCPServerCapabilitiesError when resources capability is not available.""" + """Test that resource methods return empty values when resources capability is not available.""" async with mcp_server: # Mock the capabilities to not support resources mock_capabilities = ServerCapabilities(resources=False) with patch.object(mcp_server, '_server_capabilities', mock_capabilities): - with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'): - await mcp_server.list_resources() + # list_resources should return empty list + result = await mcp_server.list_resources() + assert result == [] - with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'): - await mcp_server.list_resource_templates() + # list_resource_templates should return empty list + result = await mcp_server.list_resource_templates() + assert result == [] - with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'): - await mcp_server.read_resource('resource://test') + # read_resource should return None + result = await mcp_server.read_resource('resource://test') + assert result is None From 9804f18e804d2b8178eb5bcd71203831d30a7b28 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sat, 1 Nov 2025 17:23:43 +1100 Subject: [PATCH 22/32] Cleanup + relocate MCP errors. --- pydantic_ai_slim/pydantic_ai/exceptions.py | 54 ------------------ pydantic_ai_slim/pydantic_ai/mcp.py | 66 +++++++++++++++++++--- tests/test_mcp.py | 15 +++-- 3 files changed, 65 insertions(+), 70 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/exceptions.py b/pydantic_ai_slim/pydantic_ai/exceptions.py index be9bb4ed97..ae5cce0908 100644 --- a/pydantic_ai_slim/pydantic_ai/exceptions.py +++ b/pydantic_ai_slim/pydantic_ai/exceptions.py @@ -23,8 +23,6 @@ 'UnexpectedModelBehavior', 'UsageLimitExceeded', 'ModelHTTPError', - 'MCPError', - 'MCPServerError', 'IncompleteToolCall', 'FallbackExceptionGroup', ) @@ -161,58 +159,6 @@ def __init__(self, status_code: int, model_name: str, body: object | None = None super().__init__(message) -class MCPError(RuntimeError): - """Base class for errors occurring during interaction with an MCP server.""" - - message: str - """The error message.""" - - def __init__(self, message: str): - self.message = message - super().__init__(message) - - def __str__(self) -> str: - return self.message - - -class MCPServerError(MCPError): - """Raised when an MCP server returns an error response. - - This exception wraps error responses from MCP servers, following the ErrorData schema - from the MCP specification. - """ - - code: int - """The error code returned by the server.""" - - data: Any | None - """Additional information about the error, if provided by the server.""" - - def __init__(self, message: str, code: int, data: Any | None = None): - super().__init__(message) - self.code = code - self.data = data - - @classmethod - def from_mcp_sdk_error(cls, error: Any) -> MCPServerError: - """Create an MCPServerError from an MCP SDK McpError. - - Args: - error: An McpError from the MCP SDK. - - Returns: - A new MCPServerError instance with the error data. - """ - # Extract error data from the McpError.error attribute - error_data = error.error - return cls(message=error_data.message, code=error_data.code, data=error_data.data) - - def __str__(self) -> str: - if self.data: - return f'{self.message} (code: {self.code}, data: {self.data})' - return f'{self.message} (code: {self.code})' - - class FallbackExceptionGroup(ExceptionGroup[Any]): """A group of exceptions that can be raised when all fallback models fail.""" diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index fc521e3804..453cd57a6b 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -42,9 +42,59 @@ # after mcp imports so any import error maps to this file, not _mcp.py from . import _mcp, _utils, exceptions, messages, models -from .exceptions import MCPServerError -__all__ = 'MCPServer', 'MCPServerStdio', 'MCPServerHTTP', 'MCPServerSSE', 'MCPServerStreamableHTTP', 'load_mcp_servers' +__all__ = ( + 'MCPServer', + 'MCPServerStdio', + 'MCPServerHTTP', + 'MCPServerSSE', + 'MCPServerStreamableHTTP', + 'load_mcp_servers', + 'MCPError', +) + + +class MCPError(RuntimeError): + """Raised when an MCP server returns an error response. + + This exception wraps error responses from MCP servers, following the ErrorData schema + from the MCP specification. + """ + + message: str + """The error message.""" + + code: int + """The error code returned by the server.""" + + data: Any | None + """Additional information about the error, if provided by the server.""" + + def __init__(self, message: str, code: int, data: Any | None = None): + self.message = message + self.code = code + self.data = data + super().__init__(message) + + @classmethod + def from_mcp_sdk_error(cls, error: Any) -> MCPError: + """Create an MCPError from an MCP SDK McpError. + + Args: + error: An McpError from the MCP SDK. + + Returns: + A new MCPError instance with the error data. + """ + # Extract error data from the McpError.error attribute + error_data = error.error + return cls(message=error_data.message, code=error_data.code, data=error_data.data) + + def __str__(self) -> str: + if self.data: + return f'{self.message} (code: {self.code}, data: {self.data})' + return f'{self.message} (code: {self.code})' + TOOL_SCHEMA_VALIDATOR = pydantic_core.SchemaValidator( schema=pydantic_core.core_schema.dict_schema( @@ -322,7 +372,7 @@ async def list_resources(self) -> list[_mcp.Resource]: - We also don't subscribe to resource changes to avoid complexity. Raises: - MCPServerError: If the server returns an error. + MCPError: If the server returns an error. """ async with self: # Ensure server is running if not self.capabilities.resources: @@ -330,14 +380,14 @@ async def list_resources(self) -> list[_mcp.Resource]: try: result = await self._client.list_resources() except mcp_exceptions.McpError as e: - raise MCPServerError.from_mcp_sdk_error(e) from e + raise MCPError.from_mcp_sdk_error(e) from e return [_mcp.map_from_mcp_resource(r) for r in result.resources] async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: """Retrieve resource templates that are currently present on the server. Raises: - MCPServerError: If the server returns an error. + MCPError: If the server returns an error. """ async with self: # Ensure server is running if not self.capabilities.resources: @@ -345,7 +395,7 @@ async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: try: result = await self._client.list_resource_templates() except mcp_exceptions.McpError as e: - raise MCPServerError.from_mcp_sdk_error(e) from e + raise MCPError.from_mcp_sdk_error(e) from e return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] @overload @@ -372,7 +422,7 @@ async def read_resource( Returns `None` if the server does not support resources or the resource is not found. Raises: - MCPServerError: If the server returns an error other than resource not found. + MCPError: If the server returns an error other than resource not found. """ resource_uri = uri if isinstance(uri, str) else uri.uri async with self: # Ensure server is running @@ -384,7 +434,7 @@ async def read_resource( # As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling if e.error.code == -32002: return None - raise MCPServerError.from_mcp_sdk_error(e) from e + raise MCPError.from_mcp_sdk_error(e) from e if not result.contents: return None diff --git a/tests/test_mcp.py b/tests/test_mcp.py index deaf4a6422..616bda04f4 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -26,12 +26,11 @@ from pydantic_ai._mcp import Resource, ServerCapabilities from pydantic_ai.agent import Agent from pydantic_ai.exceptions import ( - MCPServerError, ModelRetry, UnexpectedModelBehavior, UserError, ) -from pydantic_ai.mcp import MCPServerStreamableHTTP, load_mcp_servers +from pydantic_ai.mcp import MCPError, MCPServerStreamableHTTP, load_mcp_servers from pydantic_ai.models import Model from pydantic_ai.models.test import TestModel from pydantic_ai.tools import RunContext @@ -1545,10 +1544,10 @@ async def test_read_resource_template(run_context: RunContext[int]): async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None: - """Test that read_resource raises MCPServerError for non-existent resources with non-standard error codes.""" + """Test that read_resource raises MCPError for non-existent resources with non-standard error codes.""" async with mcp_server: # FastMCP uses error code 0 instead of -32002, so it should raise - with pytest.raises(MCPServerError, match='Unknown resource: resource://does_not_exist') as exc_info: + with pytest.raises(MCPError, match='Unknown resource: resource://does_not_exist') as exc_info: await mcp_server.read_resource('resource://does_not_exist') # Verify the exception has the expected attributes @@ -1589,7 +1588,7 @@ async def test_read_resource_empty_contents(mcp_server: MCPServerStdio) -> None: async def test_list_resources_error(mcp_server: MCPServerStdio) -> None: - """Test that list_resources converts McpError to MCPServerError.""" + """Test that list_resources converts McpError to MCPError.""" mcp_error = McpError( error=ErrorData(code=-32603, message='Failed to list resources', data={'details': 'server overloaded'}) ) @@ -1600,7 +1599,7 @@ async def test_list_resources_error(mcp_server: MCPServerStdio) -> None: 'list_resources', new=AsyncMock(side_effect=mcp_error), ): - with pytest.raises(MCPServerError, match='Failed to list resources') as exc_info: + with pytest.raises(MCPError, match='Failed to list resources') as exc_info: await mcp_server.list_resources() # Verify the exception has the expected attributes @@ -1613,7 +1612,7 @@ async def test_list_resources_error(mcp_server: MCPServerStdio) -> None: async def test_list_resource_templates_error(mcp_server: MCPServerStdio) -> None: - """Test that list_resource_templates converts McpError to MCPServerError.""" + """Test that list_resource_templates converts McpError to MCPError.""" mcp_error = McpError(error=ErrorData(code=-32001, message='Service unavailable')) async with mcp_server: @@ -1622,7 +1621,7 @@ async def test_list_resource_templates_error(mcp_server: MCPServerStdio) -> None 'list_resource_templates', new=AsyncMock(side_effect=mcp_error), ): - with pytest.raises(MCPServerError, match='Service unavailable') as exc_info: + with pytest.raises(MCPError, match='Service unavailable') as exc_info: await mcp_server.list_resource_templates() # Verify the exception has the expected attributes From f7c5319c29296897cb4d9ad46f3871f4351dcf91 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 3 Nov 2025 15:12:11 +1100 Subject: [PATCH 23/32] Move public MCP resources to appropriate place. --- pydantic_ai_slim/pydantic_ai/_mcp.py | 107 ++++----------------------- pydantic_ai_slim/pydantic_ai/mcp.py | 99 +++++++++++++++++++++++-- tests/test_mcp.py | 3 +- 3 files changed, 108 insertions(+), 101 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_mcp.py b/pydantic_ai_slim/pydantic_ai/_mcp.py index a4000044ac..f2fd30bb74 100644 --- a/pydantic_ai_slim/pydantic_ai/_mcp.py +++ b/pydantic_ai_slim/pydantic_ai/_mcp.py @@ -1,12 +1,8 @@ import base64 -from abc import ABC from collections.abc import Sequence -from dataclasses import dataclass -from typing import Annotated, Any, Literal +from typing import TYPE_CHECKING, Literal -from pydantic import Field - -from . import _utils, exceptions, messages +from . import exceptions, messages try: from mcp import types as mcp_types @@ -16,87 +12,8 @@ 'you can use the `mcp` optional group — `pip install "pydantic-ai-slim[mcp]"`' ) from _import_error - -@dataclass(repr=False, kw_only=True) -class ResourceAnnotations: - """Additional properties describing MCP entities.""" - - audience: list[mcp_types.Role] | None = None - """Intended audience for this entity.""" - - priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None - """Priority level for this entity, ranging from 0.0 to 1.0.""" - - __repr__ = _utils.dataclasses_no_defaults_repr - - -@dataclass(repr=False, kw_only=True) -class BaseResource(ABC): - """Base class for MCP resources.""" - - name: str - """The programmatic name of the resource.""" - - title: str | None = None - """Human-readable title for UI contexts.""" - - description: str | None = None - """A description of what this resource represents.""" - - mime_type: str | None = None - """The MIME type of the resource, if known.""" - - annotations: ResourceAnnotations | None = None - """Optional annotations for the resource.""" - - meta: dict[str, Any] | None = None - """Optional metadata for the resource.""" - - __repr__ = _utils.dataclasses_no_defaults_repr - - -@dataclass(repr=False, kw_only=True) -class Resource(BaseResource): - """A resource that can be read from an MCP server.""" - - uri: str - """The URI of the resource.""" - - size: int | None = None - """The size of the raw resource content in bytes (before base64 encoding), if known.""" - - -@dataclass(repr=False, kw_only=True) -class ResourceTemplate(BaseResource): - """A template for parameterized resources on an MCP server.""" - - uri_template: str - """URI template (RFC 6570) for constructing resource URIs.""" - - -@dataclass(repr=False, kw_only=True) -class ServerCapabilities: - """Capabilities that an MCP server supports.""" - - experimental: list[str] | None = None - """Experimental, non-standard capabilities that the server supports.""" - - logging: bool = False - """Whether the server supports sending log messages to the client.""" - - prompts: bool = False - """Whether the server offers any prompt templates.""" - - resources: bool = False - """Whether the server offers any resources to read.""" - - tools: bool = False - """Whether the server offers any tools to call.""" - - completions: bool = False - """Whether the server offers autocompletion suggestions for prompts and resources.""" - - __repr__ = _utils.dataclasses_no_defaults_repr +if TYPE_CHECKING: + from .mcp import Resource, ResourceTemplate, ServerCapabilities def map_from_mcp_params(params: mcp_types.CreateMessageRequestParams) -> list[messages.ModelMessage]: @@ -209,8 +126,10 @@ def map_from_sampling_content( raise NotImplementedError('Image and Audio responses in sampling are not yet supported') -def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> Resource: +def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> 'Resource': """Convert from MCP Resource to native Pydantic AI Resource.""" + from .mcp import Resource, ResourceAnnotations + return Resource( uri=str(mcp_resource.uri), name=mcp_resource.name, @@ -223,12 +142,14 @@ def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> Resource: if mcp_resource.annotations else None ), - meta=mcp_resource.meta, + metadata=mcp_resource.meta, ) -def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> ResourceTemplate: +def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> 'ResourceTemplate': """Convert from MCP ResourceTemplate to native Pydantic AI ResourceTemplate.""" + from .mcp import ResourceAnnotations, ResourceTemplate + return ResourceTemplate( uri_template=mcp_template.uriTemplate, name=mcp_template.name, @@ -240,12 +161,14 @@ def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> if mcp_template.annotations else None ), - meta=mcp_template.meta, + metadata=mcp_template.meta, ) -def map_from_mcp_server_capabilities(mcp_capabilities: mcp_types.ServerCapabilities) -> ServerCapabilities: +def map_from_mcp_server_capabilities(mcp_capabilities: mcp_types.ServerCapabilities) -> 'ServerCapabilities': """Convert from MCP ServerCapabilities to native Pydantic AI ServerCapabilities.""" + from .mcp import ServerCapabilities + return ServerCapabilities( experimental=list(mcp_capabilities.experimental.keys()) if mcp_capabilities.experimental else None, logging=mcp_capabilities.logging is not None, diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 453cd57a6b..7747578218 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -7,7 +7,7 @@ from asyncio import Lock from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from dataclasses import field, replace +from dataclasses import dataclass, field, replace from datetime import timedelta from pathlib import Path from typing import Annotated, Any, overload @@ -51,6 +51,9 @@ 'MCPServerStreamableHTTP', 'load_mcp_servers', 'MCPError', + 'Resource', + 'ResourceTemplate', + 'ServerCapabilities', ) @@ -96,6 +99,88 @@ def __str__(self) -> str: return f'{self.message} (code: {self.code})' +@dataclass(repr=False, kw_only=True) +class ResourceAnnotations: + """Additional properties describing MCP entities.""" + + audience: list[mcp_types.Role] | None = None + """Intended audience for this entity.""" + + priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None + """Priority level for this entity, ranging from 0.0 to 1.0.""" + + __repr__ = _utils.dataclasses_no_defaults_repr + + +@dataclass(repr=False, kw_only=True) +class BaseResource(ABC): + """Base class for MCP resources.""" + + name: str + """The programmatic name of the resource.""" + + title: str | None = None + """Human-readable title for UI contexts.""" + + description: str | None = None + """A description of what this resource represents.""" + + mime_type: str | None = None + """The MIME type of the resource, if known.""" + + annotations: ResourceAnnotations | None = None + """Optional annotations for the resource.""" + + metadata: dict[str, Any] | None = None + """Optional metadata for the resource.""" + + __repr__ = _utils.dataclasses_no_defaults_repr + + +@dataclass(repr=False, kw_only=True) +class Resource(BaseResource): + """A resource that can be read from an MCP server.""" + + uri: str + """The URI of the resource.""" + + size: int | None = None + """The size of the raw resource content in bytes (before base64 encoding), if known.""" + + +@dataclass(repr=False, kw_only=True) +class ResourceTemplate(BaseResource): + """A template for parameterized resources on an MCP server.""" + + uri_template: str + """URI template (RFC 6570) for constructing resource URIs.""" + + +@dataclass(repr=False, kw_only=True) +class ServerCapabilities: + """Capabilities that an MCP server supports.""" + + experimental: list[str] | None = None + """Experimental, non-standard capabilities that the server supports.""" + + logging: bool = False + """Whether the server supports sending log messages to the client.""" + + prompts: bool = False + """Whether the server offers any prompt templates.""" + + resources: bool = False + """Whether the server offers any resources to read.""" + + tools: bool = False + """Whether the server offers any tools to call.""" + + completions: bool = False + """Whether the server offers autocompletion suggestions for prompts and resources.""" + + __repr__ = _utils.dataclasses_no_defaults_repr + + TOOL_SCHEMA_VALIDATOR = pydantic_core.SchemaValidator( schema=pydantic_core.core_schema.dict_schema( pydantic_core.core_schema.str_schema(), pydantic_core.core_schema.any_schema() @@ -164,7 +249,7 @@ class MCPServer(AbstractToolset[Any], ABC): _read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] _write_stream: MemoryObjectSendStream[SessionMessage] _server_info: mcp_types.Implementation - _server_capabilities: _mcp.ServerCapabilities + _server_capabilities: ServerCapabilities def __init__( self, @@ -244,7 +329,7 @@ def server_info(self) -> mcp_types.Implementation: return self._server_info @property - def capabilities(self) -> _mcp.ServerCapabilities: + def capabilities(self) -> ServerCapabilities: """Access the capabilities advertised by the MCP server during initialization.""" if getattr(self, '_server_capabilities', None) is None: raise AttributeError( @@ -364,7 +449,7 @@ def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[Any]: args_validator=TOOL_SCHEMA_VALIDATOR, ) - async def list_resources(self) -> list[_mcp.Resource]: + async def list_resources(self) -> list[Resource]: """Retrieve resources that are currently present on the server. Note: @@ -383,7 +468,7 @@ async def list_resources(self) -> list[_mcp.Resource]: raise MCPError.from_mcp_sdk_error(e) from e return [_mcp.map_from_mcp_resource(r) for r in result.resources] - async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: + async def list_resource_templates(self) -> list[ResourceTemplate]: """Retrieve resource templates that are currently present on the server. Raises: @@ -405,11 +490,11 @@ async def read_resource( @overload async def read_resource( - self, uri: _mcp.Resource + self, uri: Resource ) -> str | messages.BinaryContent | list[str | messages.BinaryContent] | None: ... async def read_resource( - self, uri: str | _mcp.Resource + self, uri: str | Resource ) -> str | messages.BinaryContent | list[str | messages.BinaryContent] | None: """Read the contents of a specific resource by URI. diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 616bda04f4..0c909f7a86 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -23,14 +23,13 @@ ToolReturnPart, UserPromptPart, ) -from pydantic_ai._mcp import Resource, ServerCapabilities from pydantic_ai.agent import Agent from pydantic_ai.exceptions import ( ModelRetry, UnexpectedModelBehavior, UserError, ) -from pydantic_ai.mcp import MCPError, MCPServerStreamableHTTP, load_mcp_servers +from pydantic_ai.mcp import MCPError, MCPServerStreamableHTTP, Resource, ServerCapabilities, load_mcp_servers from pydantic_ai.models import Model from pydantic_ai.models.test import TestModel from pydantic_ai.tools import RunContext From 21ad2b0d63a16629bbd36a762805cfc3ece5b332 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 3 Nov 2025 15:17:22 +1100 Subject: [PATCH 24/32] Improve clarity of MCP Resource handling documentation. --- docs/mcp/client.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 3fb15f91df..1b50682461 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -313,7 +313,7 @@ MCP tools can include metadata that provides additional information about the to ## Resources -MCP servers can provide [resources](https://modelcontextprotocol.io/docs/concepts/resources) - files, data, or content that can be accessed by the client. Resources in MCP are designed to be application-driven, with host applications determining how to incorporate context based on their needs. +MCP servers can provide [resources](https://modelcontextprotocol.io/docs/concepts/resources) - files, data, or content that can be accessed by the client. Resources in MCP are application-driven, with host applications determining how to incorporate context manually, based on their needs. This means they will _not_ be exposed to the LLM automatically (unless a tool returns a ResourceLink). Pydantic AI provides methods to discover and read resources from MCP servers: From 14c3973f58a88756df3715c93e68e5b3b3a1da21 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Mon, 3 Nov 2025 15:24:47 +1100 Subject: [PATCH 25/32] Test cleanups. --- pydantic_ai_slim/pydantic_ai/mcp.py | 1 + tests/test_mcp.py | 57 ++++++++++++++++------------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 7747578218..cc029af4bb 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -52,6 +52,7 @@ 'load_mcp_servers', 'MCPError', 'Resource', + 'ResourceAnnotations', 'ResourceTemplate', 'ServerCapabilities', ) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 0c909f7a86..a7bbc40ec6 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -29,7 +29,15 @@ UnexpectedModelBehavior, UserError, ) -from pydantic_ai.mcp import MCPError, MCPServerStreamableHTTP, Resource, ServerCapabilities, load_mcp_servers +from pydantic_ai.mcp import ( + MCPError, + MCPServerStreamableHTTP, + Resource, + ResourceAnnotations, + ResourceTemplate, + ServerCapabilities, + load_mcp_servers, +) from pydantic_ai.models import Model from pydantic_ai.models.test import TestModel from pydantic_ai.tools import RunContext @@ -328,36 +336,35 @@ async def test_stdio_server_list_resources(run_context: RunContext[int]): server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) async with server: resources = await server.list_resources() - assert len(resources) == snapshot(3) - - assert resources[0].uri == snapshot('resource://kiwi.png') - assert resources[0].mime_type == snapshot('image/png') - assert resources[0].name == snapshot('kiwi_resource') - assert resources[0].annotations is None - - assert resources[1].uri == snapshot('resource://marcelo.mp3') - assert resources[1].mime_type == snapshot('audio/mpeg') - assert resources[1].name == snapshot('marcelo_resource') - assert resources[1].annotations is None - - assert resources[2].uri == snapshot('resource://product_name.txt') - assert resources[2].mime_type == snapshot('text/plain') - assert resources[2].name == snapshot('product_name_resource') - # Test ResourceAnnotations - assert resources[2].annotations is not None - assert resources[2].annotations.audience == snapshot(['user', 'assistant']) - assert resources[2].annotations.priority == snapshot(0.5) + assert resources == snapshot( + [ + Resource(name='kiwi_resource', description='', mime_type='image/png', uri='resource://kiwi.png'), + Resource(name='marcelo_resource', description='', mime_type='audio/mpeg', uri='resource://marcelo.mp3'), + Resource( + name='product_name_resource', + description='', + mime_type='text/plain', + annotations=ResourceAnnotations(audience=['user', 'assistant'], priority=0.5), + uri='resource://product_name.txt', + ), + ] + ) async def test_stdio_server_list_resource_templates(run_context: RunContext[int]): server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) async with server: resource_templates = await server.list_resource_templates() - assert len(resource_templates) == snapshot(1) - - assert resource_templates[0].uri_template == snapshot('resource://greeting/{name}') - assert resource_templates[0].name == snapshot('greeting_resource_template') - assert resource_templates[0].description == snapshot('Dynamic greeting resource template.') + assert resource_templates == snapshot( + [ + ResourceTemplate( + name='greeting_resource_template', + description='Dynamic greeting resource template.', + mime_type='text/plain', + uri_template='resource://greeting/{name}', + ) + ] + ) async def test_log_level_set(run_context: RunContext[int]): From 44c327a8aa10dec80447e0dbe46f87bc9fc97170 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Sat, 1 Nov 2025 19:43:18 +1100 Subject: [PATCH 26/32] Improve type safety on MCPError. --- pydantic_ai_slim/pydantic_ai/mcp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index cc029af4bb..b6a978ffb4 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -71,17 +71,17 @@ class MCPError(RuntimeError): code: int """The error code returned by the server.""" - data: Any | None + data: dict[str, Any] | None """Additional information about the error, if provided by the server.""" - def __init__(self, message: str, code: int, data: Any | None = None): + def __init__(self, message: str, code: int, data: dict[str, Any] | None = None): self.message = message self.code = code self.data = data super().__init__(message) @classmethod - def from_mcp_sdk_error(cls, error: Any) -> MCPError: + def from_mcp_sdk_error(cls, error: mcp_exceptions.McpError) -> MCPError: """Create an MCPError from an MCP SDK McpError. Args: From 734606168f77f38efd144f40b0d1bd11623b492c Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Wed, 5 Nov 2025 17:10:10 +1100 Subject: [PATCH 27/32] Update docs/mcp/client.md Co-authored-by: Douwe Maan --- docs/mcp/client.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 1b50682461..60786814a5 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -313,7 +313,7 @@ MCP tools can include metadata that provides additional information about the to ## Resources -MCP servers can provide [resources](https://modelcontextprotocol.io/docs/concepts/resources) - files, data, or content that can be accessed by the client. Resources in MCP are application-driven, with host applications determining how to incorporate context manually, based on their needs. This means they will _not_ be exposed to the LLM automatically (unless a tool returns a ResourceLink). +MCP servers can provide [resources](https://modelcontextprotocol.io/docs/concepts/resources) - files, data, or content that can be accessed by the client. Resources in MCP are application-driven, with host applications determining how to incorporate context manually, based on their needs. This means they will _not_ be exposed to the LLM automatically (unless a tool returns a `ResourceLink` or `EmbeddedResource`). Pydantic AI provides methods to discover and read resources from MCP servers: From b9216b4aca6321cf2ec8168c09b919187f849975 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Wed, 5 Nov 2025 20:46:35 +1100 Subject: [PATCH 28/32] Update MCP resources docs example. --- docs/mcp/client.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 60786814a5..19bffc4dfa 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -346,22 +346,7 @@ Then we can create the client: ```python {title="mcp_resources.py", requires="mcp_resource_server.py"} import asyncio -from pydantic_ai import Agent -from pydantic_ai._run_context import RunContext from pydantic_ai.mcp import MCPServerStdio -from pydantic_ai.models.test import TestModel - -agent = Agent( - model=TestModel(), - deps_type=str, - instructions="Use the customer's name while replying to them.", -) - - -@agent.instructions -def add_the_users_name(ctx: RunContext[str]) -> str: - return f"The user's name is {ctx.deps}." - async def main(): server = MCPServerStdio('python', args=['-m', 'mcp_resource_server']) @@ -378,10 +363,6 @@ async def main(): print(f'Text content: {user_name}') #> Text content: Alice - # Use resources in dependencies - async with agent: - _ = await agent.run('Can you help me with my product?', deps=user_name) - if __name__ == '__main__': asyncio.run(main()) From 3d4734949e01b35a89ac1d129895496ac34bb73b Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Wed, 5 Nov 2025 21:03:28 +1100 Subject: [PATCH 29/32] Docs cleanup. --- docs/mcp/client.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 19bffc4dfa..b8678f3479 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -348,6 +348,7 @@ import asyncio from pydantic_ai.mcp import MCPServerStdio + async def main(): server = MCPServerStdio('python', args=['-m', 'mcp_resource_server']) From 278d14101622901d741bde52423e9cb9a09dc05e Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Wed, 5 Nov 2025 21:06:52 +1100 Subject: [PATCH 30/32] Cleanup MCP resource tests. --- tests/test_mcp.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index a7bbc40ec6..3d8a2d3ce3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1550,20 +1550,12 @@ async def test_read_resource_template(run_context: RunContext[int]): async def test_read_resource_not_found(mcp_server: MCPServerStdio) -> None: - """Test that read_resource raises MCPError for non-existent resources with non-standard error codes.""" - async with mcp_server: - # FastMCP uses error code 0 instead of -32002, so it should raise - with pytest.raises(MCPError, match='Unknown resource: resource://does_not_exist') as exc_info: - await mcp_server.read_resource('resource://does_not_exist') - - # Verify the exception has the expected attributes - assert exc_info.value.code == 0 - assert exc_info.value.message == 'Unknown resource: resource://does_not_exist' + """Test that read_resource returns None for MCP spec error code -32002 (resource not found). + As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling -async def test_read_resource_not_found_mcp_spec(mcp_server: MCPServerStdio) -> None: - """Test that read_resource returns None for MCP spec error code -32002 (resource not found).""" - # As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling + Note: We mock this because FastMCP uses error code 0 instead of -32002, which is non-standard. + """ mcp_error = McpError(error=ErrorData(code=-32002, message='Resource not found')) async with mcp_server: @@ -1576,6 +1568,27 @@ async def test_read_resource_not_found_mcp_spec(mcp_server: MCPServerStdio) -> N assert result is None +async def test_read_resource_error(mcp_server: MCPServerStdio) -> None: + """Test that read_resource converts McpError to MCPError for generic errors.""" + mcp_error = McpError( + error=ErrorData(code=-32603, message='Failed to read resource', data={'details': 'disk error'}) + ) + + async with mcp_server: + with patch.object( + mcp_server._client, # pyright: ignore[reportPrivateUsage] + 'read_resource', + new=AsyncMock(side_effect=mcp_error), + ): + with pytest.raises(MCPError, match='Failed to read resource') as exc_info: + await mcp_server.read_resource('resource://error') + + # Verify the exception has the expected attributes + assert exc_info.value.code == -32603 + assert exc_info.value.message == 'Failed to read resource' + assert exc_info.value.data == {'details': 'disk error'} + + async def test_read_resource_empty_contents(mcp_server: MCPServerStdio) -> None: """Test that read_resource returns None when server returns empty contents.""" from mcp.types import ReadResourceResult From 2445540f6c1ca457b306cdecfb60e0370782a6be Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Wed, 5 Nov 2025 21:43:09 +1100 Subject: [PATCH 31/32] Make error handing in MCP ResourceLink reading consistent. --- pydantic_ai_slim/pydantic_ai/mcp.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index b6a978ffb4..48576d4ddb 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -627,8 +627,11 @@ async def _map_tool_result_part( return self._get_content(resource) elif isinstance(part, mcp_types.ResourceLink): result = await self.read_resource(str(part.uri)) - # If resource not found, return an empty string as it's impossible to fetch anyway - return result if result is not None else '' + # Rather than hide an invalid resource link, we raise an error so it's consistent with any + # other error that could happen during resource reading. + if result is None: + raise MCPError(message=f'Invalid ResourceLink {part.uri} returned by tool', code=-32002) + return result else: assert_never(part) From 509d6ef089edbab70b12fe5e1ca7818248dbad97 Mon Sep 17 00:00:00 2001 From: Fenn Bailey Date: Thu, 6 Nov 2025 12:54:09 +1100 Subject: [PATCH 32/32] Use future import annotations for cleaner syntax. --- pydantic_ai_slim/pydantic_ai/_mcp.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_mcp.py b/pydantic_ai_slim/pydantic_ai/_mcp.py index f2fd30bb74..624a795f85 100644 --- a/pydantic_ai_slim/pydantic_ai/_mcp.py +++ b/pydantic_ai_slim/pydantic_ai/_mcp.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 from collections.abc import Sequence from typing import TYPE_CHECKING, Literal @@ -126,7 +128,7 @@ def map_from_sampling_content( raise NotImplementedError('Image and Audio responses in sampling are not yet supported') -def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> 'Resource': +def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> Resource: """Convert from MCP Resource to native Pydantic AI Resource.""" from .mcp import Resource, ResourceAnnotations @@ -146,7 +148,7 @@ def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> 'Resource': ) -def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> 'ResourceTemplate': +def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> ResourceTemplate: """Convert from MCP ResourceTemplate to native Pydantic AI ResourceTemplate.""" from .mcp import ResourceAnnotations, ResourceTemplate @@ -165,7 +167,7 @@ def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> ) -def map_from_mcp_server_capabilities(mcp_capabilities: mcp_types.ServerCapabilities) -> 'ServerCapabilities': +def map_from_mcp_server_capabilities(mcp_capabilities: mcp_types.ServerCapabilities) -> ServerCapabilities: """Convert from MCP ServerCapabilities to native Pydantic AI ServerCapabilities.""" from .mcp import ServerCapabilities