diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 59f96bf865..3fb15f91df 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -311,6 +311,85 @@ agent = Agent('openai:gpt-5', 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 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: + +```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 + +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']) + + async with server: + # List all available resources + resources = await server.list_resources() + for resource in resources: + print(f' - {resource.name}: {resource.uri} ({resource.mime_type})') + #> - user_name_resource: resource://user_name.txt (text/plain) + + # Read a text resource + 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: + _ = await agent.run('Can you help me with my product?', deps=user_name) + + +if __name__ == '__main__': + asyncio.run(main()) +``` + +_(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 – diff --git a/pydantic_ai_slim/pydantic_ai/_mcp.py b/pydantic_ai_slim/pydantic_ai/_mcp.py index 1e09246ccc..a4000044ac 100644 --- a/pydantic_ai_slim/pydantic_ai/_mcp.py +++ b/pydantic_ai_slim/pydantic_ai/_mcp.py @@ -1,8 +1,12 @@ import base64 +from abc import ABC from collections.abc import Sequence -from typing import Literal +from dataclasses import dataclass +from typing import Annotated, Any, Literal -from . import exceptions, messages +from pydantic import Field + +from . import _utils, exceptions, messages try: from mcp import types as mcp_types @@ -13,6 +17,88 @@ ) 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 + + 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 +207,50 @@ 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=( + ResourceAnnotations(audience=mcp_resource.annotations.audience, priority=mcp_resource.annotations.priority) + 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=( + ResourceAnnotations(audience=mcp_template.annotations.audience, priority=mcp_template.annotations.priority) + if mcp_template.annotations + else None + ), + 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/exceptions.py b/pydantic_ai_slim/pydantic_ai/exceptions.py index ae5cce0908..8aa8da5dc1 100644 --- a/pydantic_ai_slim/pydantic_ai/exceptions.py +++ b/pydantic_ai_slim/pydantic_ai/exceptions.py @@ -23,6 +23,9 @@ 'UnexpectedModelBehavior', 'UsageLimitExceeded', 'ModelHTTPError', + 'MCPError', + 'MCPServerCapabilitiesError', + 'MCPServerError', 'IncompleteToolCall', 'FallbackExceptionGroup', ) @@ -159,6 +162,62 @@ 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 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. + + 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 70364c0ad4..2a37809437 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -10,13 +10,13 @@ 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 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 @@ -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' @@ -113,6 +114,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 +193,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. @@ -236,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: @@ -303,6 +314,80 @@ def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[Any]: args_validator=TOOL_SCHEMA_VALIDATOR, ) + async def list_resources(self) -> list[_mcp.Resource]: + """Retrieve resources that are currently present on the server. + + Note: + - We don't cache resources as they might change. + - 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') + try: + result = await self._client.list_resources() + 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: + 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') + try: + result = await self._client.list_resource_templates() + 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 + 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, 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. + + Raises: + 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 MCPServerCapabilitiesError('Server does not support resources capability') + try: + result = await self._client.read_resource(AnyUrl(resource_uri)) + 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 + else [self._get_content(resource) for resource in result.contents] + ) + async def __aenter__(self) -> Self: """Enter the MCP server context. @@ -328,6 +413,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) @@ -397,12 +483,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/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index 54538f1deb..99e9b76f1b 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -92,7 +92,7 @@ cli = [ "pyperclip>=1.9.0", ] # MCP -mcp = ["mcp>=1.12.3"] +mcp = ["mcp>=1.18.0"] # FastMCP fastmcp = ["fastmcp>=2.12.0"] # Evals diff --git a/tests/mcp_server.py b/tests/mcp_server.py index 54b105ab29..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,11 +121,21 @@ 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() +@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 9342f49f10..33f85fa73c 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -23,8 +23,15 @@ 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.exceptions import ( + MCPServerCapabilitiesError, + MCPServerError, + ModelRetry, + UnexpectedModelBehavior, + UserError, +) from pydantic_ai.mcp import MCPServerStreamableHTTP, load_mcp_servers from pydantic_ai.models import Model from pydantic_ai.models.test import TestModel @@ -37,7 +44,13 @@ 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 ( + CreateMessageRequestParams, + ElicitRequestParams, + ElicitResult, + ImageContent, + TextContent, + ) from pydantic_ai._mcp import map_from_mcp_params, map_from_model_response from pydantic_ai.mcp import CallToolFunc, MCPServerSSE, MCPServerStdio, ToolResult @@ -314,6 +327,42 @@ 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].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]): + 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.') + + 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' @@ -1460,6 +1509,95 @@ 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 (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).""" + server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) + async with server: + 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 (converted to string).""" + server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) + async with server: + content = await server.read_resource('resource://greeting/Alice') + assert isinstance(content, str) + 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' + + +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', data={'details': 'server overloaded'}) + ) + + 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' + 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: + """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' @@ -1489,3 +1627,34 @@ 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 + + +async def test_resource_methods_without_capability(mcp_server: MCPServerStdio) -> None: + """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(MCPServerCapabilitiesError, match='Server does not support resources capability'): + await mcp_server.list_resources() + + with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'): + await mcp_server.list_resource_templates() + + with pytest.raises(MCPServerCapabilitiesError, match='Server does not support resources capability'): + await mcp_server.read_resource('resource://test') diff --git a/uv.lock b/uv.lock index d311853566..877f7a6f60 100644 --- a/uv.lock +++ b/uv.lock @@ -3015,7 +3015,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.13.1" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3030,9 +3030,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] @@ -5625,7 +5625,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" },