Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions docs/mcp/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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)


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 –
Expand Down
32 changes: 31 additions & 1 deletion pydantic_ai_slim/pydantic_ai/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -275,6 +275,36 @@ 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 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.
"""
async with self: # Ensure server is running
result = await self._client.list_resources()
return result.resources

async def list_resource_templates(self) -> list[mcp_types.ResourceTemplate]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kludex You suggested having these methods return types of our own, but I'm not super convinced it's worth it to redefine this:

class ResourceTemplate(BaseMetadata):
    """A template description for resources available on the server."""

    uriTemplate: str
    """
    A URI template (according to RFC 6570) that can be used to construct resource
    URIs.
    """
    description: str | None = None
    """A human-readable description of what this template is for."""
    mimeType: str | None = None
    """
    The MIME type for all resources that match this template. This should only be
    included if all resources matching this template have the same type.
    """
    annotations: Annotations | None = None
    meta: dict[str, Any] | None = Field(alias="_meta", default=None)
    """
    See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
    for notes on _meta usage.
    """
    model_config = ConfigDict(extra="allow")

And Annotations that it references:

class Annotations(BaseModel):
    audience: list[Role] | None = None
    priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None
    model_config = ConfigDict(extra="allow"

And Role that that references:

Role = Literal["user", "assistant"]

... instead of just letting the user use MCP types directly for certain things. Although I suppose in that case, instructing users to just use client directly to get access to MCP stuff directly would suffice as well.

There's also the fact that list_tools already returns list[mcp_types.Tool].

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I'm fine with this. @fennb Can you define our own matching types please, as dataclasses with snake_case fields rather than camelCase?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think it's one or the other, right? Either just expose client directly and let users use the native mcp types or convert the types and expose directly on MCPServer?

The place I've left it (which is kind of a mix) is probably "wrong" now that I reflect on it.

Happy to take this further and try to add the native type conversion (or @Kludex can run with it) if we think this is the right path.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah, we submitted comments at the same time @DouweM - just saw your most recent comment. I'll run with this this direction and push an update.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are not redefining the types, I don't think we should expose those methods. I think exposing the client would be enough.


Is it really worth defining those types here, tho? 👀

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kludex If we'll have read_resource that returns our types, which would be useful, I think we should also have list_resources, which should then also return our types

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really worth defining those types here, tho?

Great question.

@Kludex If we'll have read_resource that returns our types, which would be useful, I think we should also have list_resources, which should then also return our types

Yeah, having had a bit more of a chance to look at/think about this, I think this is where I landed too. All public MCPServer methods should return native Pydantic AI types, which is (sort of) why MCPServer wraps mcp's ClientSession in the first place.

I'll get going on implementing this and you guys can let me know what you think.

Side note 1: list_tools(self) -> list[mcp_types.Tool] is an oddity and I suspect shouldn't exist (or shouldn't be public) - MCPServer.get_tools() is the only thing that uses it and is probably the right "public" method for users to use. Do we want to do anything here?

Side note 2: As part of investigating things, I looked into the origins of the ResourceLink / _get_content() functionality mentioned above: #2094 (comment) - I'm not sure this behaviour is quite right (though I absolutely understand why the decision was made that way). The MCP spec says that tools can return both Resource Links and Embedded Resources, but they're not the same thing (see here).

At the moment, Pydantic transparently fetches resources from the link and returns them inline to the model as part of the tool call result. But surely that's not the intended use given the fact there's already embedded resources as a separate type?

ie: Imagine a hypothetical tool called find_relevant_documents(query: str, limit: int = 50) that returned 50 resource links to documents (each linked document being 1MB) so that a user can click on the one they want to load/select it/whatever. At the moment, Pydantic AI would try to fetch all the docs immediately and include them in context transparently, which seems... wrong.

An approach would be to leave them as links and then it's up to the user to prompt the agent appropriately in telling it what to do with them (ie: display them verbatim for the actual client to handle or whatever). This is less controversial if the ResourceLink.uri is https://some.server.com/path/file.ext but weirder if it's document://legal/archive/blah.txt (or whatever). I don't really know what an agent would do in that case. Arguably, ResourceLinks maybe shouldn't be used this way, but I think blindly fetching them may not be the right default?

Either way, I've gotten off topic and can separately lodge the above as a separate issue if you think it's worth it. I don't know how much changing this will break existing users expectations or not.

Copy link
Collaborator

@DouweM DouweM Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_tools(self) -> list[mcp_types.Tool] is an oddity and I suspect shouldn't exist (or shouldn't be public) - MCPServer.get_tools() is the only thing that uses it and is probably the right "public" method for users to use. Do we want to do anything here?

I agree, but we can't change that as it'd be a breaking change and we're on v1 now so can't make those until v2.


I agree the resource link behavior is probably incorrect. I tried to get clarification on that in modelcontextprotocol/modelcontextprotocol#872 (reply in thread), but didn't hear back. It would be easier if the model had a read_resource tool available to it, so it can choose which to load, but that'd need to be provided by the user. Maybe MCPServer should have a flag to include that as a tool, and if provided, we wouldn't auto-read resource links?

A separate issue would be great!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't even think about list_tools because it's called internally anyway, but yeah, I agree it should return the PydanticAI types.

As for the new methods, I think they should return the PydanticAI types - where are we on this?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good timing (and sorry for slow follow up). Just pushed updates. This is, admittedly, a fair amount of boilerplate to not achieve a massive amount (at this point), but I can see some future reasons related to my commentary on ResourceLink above that this will become more important (more on this in the other issue which I'll create shortly).

"""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 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the same logic we have in the ResourceLink handling below, to turn this into Pydantic AI native types?

            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]
            )

We can then also use this method there!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that would mean we'd lose the meta field (as pointed out in #2288), as we don't currently have metadata on BinaryContent, let alone on str.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kludex What do you think?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I was thinking of doing this (and had exactly the same thought) but was trying to keep the initial PR as minimal as possible.

Using this approach would definitely justify proxying the method (rather than just exposing ClientSession directly like @Kludex's PR).

I think converting the types would make it more consistent with how the rest of the system.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fennb Yeah I'm inclined to convert the objects, despite us losing the metadata, and if the user needs it, they can use the client directly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to lose the metadata for now.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My most recent updates means that read_resource() now (effectively) returns str | BinaryContent (as discussed).

In this particular context, could we map Resource.meta to BinaryContent.vendor_metadata? If this is only being used in an MCPServer.read_resource() context, or would this cause issues?

Also, slightly crazy idea, but to solve losing meta on str results, what about something like:

class TextContent(str):
     meta: dict[str, Any] | None

    def __new__(cls, value, meta: dict[str, Any] | None = None):
        instance = str.__new__(cls, value)
        instance.meta = meta
        return instance

So it still is a str, it just has bonus extra bits? (I have no intuition what real-world issues this might cause).


async def __aenter__(self) -> Self:
"""Enter the MCP server context.

Expand Down
6 changes: 6 additions & 0 deletions tests/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
86 changes: 85 additions & 1 deletion tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'

Expand Down