-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Feature: MCP client Resources support #3024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
9fde764
a7404a2
ecea466
1886299
dfb54db
f52bb71
232ff04
38675da
317416c
67f9b07
e6cb086
b8424d8
50cab26
6857ef2
18743cd
4e5d627
6f3a87a
72d66ac
3000511
30f4e7f
456e714
3d95521
918f85c
28cf761
9804f18
d173f88
f7c5319
21ad2b0
14c3973
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -322,6 +322,90 @@ 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. | ||
|
|
||
| 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 mcp.types import TextResourceContents | ||
|
|
||
| from pydantic_ai import Agent | ||
| from pydantic_ai._run_context import RunContext | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we import this from |
||
| 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.mimeType})') | ||
| #> - 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 | ||
|
|
||
| # 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can drop the agent from this example entirely |
||
|
|
||
|
|
||
| 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 – | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -318,6 +326,36 @@ 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 str(resources[0].uri) == snapshot('resource://kiwi.png') | ||
| assert resources[0].mimeType == 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].name == snapshot('marcelo_resource') | ||
|
|
||
| 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') | ||
|
|
||
|
|
||
| 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' | ||
|
|
@@ -1394,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' | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's make it extra explicit that resources are not automatically shared with the LLM, unless a tool returns a resource link.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed.