Skip to content

Conversation

@fennb
Copy link

@fennb fennb commented Sep 29, 2025

Addresses #1783.

Adds support for MCP Resources to the client.

Changes

Resource Methods

  • list_resources() - Discover available resources on the server
  • list_resource_templates() - List parameterized resource templates (RFC 6570)
  • read_resource(uri) - Read resource contents, accepting string URI or Resource object

Native Types

  • Introduces Resource, ResourceTemplate, ResourceAnnotations, and ServerCapabilities types
  • Automatically converts text content to str and binary content to BinaryContent
  • Avoids exposing upstream mcp library types in the public API

Error Handling

  • Introduces MCP exception types MCPServerCapabilitiesError, MCPServerError
  • Captures MCP SDK errors context (error code and data)
  • Capability checking (fast-fail) before attempting resource operations

Other

  • Added MCPServer.capabilities property to inspect server features
  • Updated MCP dependency to mcp>=1.18.0 (required for Annotations tests)

@DouweM DouweM self-assigned this Sep 30, 2025
@DouweM DouweM requested a review from Kludex September 30, 2025 20:54
@phemmer
Copy link
Contributor

phemmer commented Oct 12, 2025

I was just looking at this as I need resources support, and noticed this doesn't provide any way to determine whether the server even supports resources. The only way to tell with this code would be to attempt to make a resource call (e.g. read_resource) and wait for a failure.

I think this should be better exposed as I would like my application to switch to alternate behavior if the MCP server does not support resources. And would like to do so without having to make a call and see if it fails, and then differentiate whether that failure was because of lack of support, or some other reason.

The MCP spec says that the resources capability is used to advertise resources support. However from looking at the code, only serverInfo is captured from the initialize result, and the rest of the initialize response (which includes resources support) is thrown away.

https://github.com/fennb/pydantic-ai/blob/b8424d80eeeb46aef3ad8ae49ae29b1ef820c7af/pydantic_ai_slim/pydantic_ai/mcp.py#L375-L378

@phemmer
Copy link
Contributor

phemmer commented Oct 12, 2025

Another thing I noticed going through the code, is that for EmbeddedResource handling, the metadata is being thrown away. I need to know the URI of the resource.

I'm not sure if addressing this is even in scope of this PR, as ResourceLink handling is also bad, as it automatically fetches the content instead of providing the link. But issue #3099 addresses this. So I'm not sure if this EmbeddedResource metadata loss issue goes here, there, or in a new issue entirely. But figured it would be at least worth mentioning here.

@fennb
Copy link
Author

fennb commented Oct 12, 2025

The MCP spec says that the resources capability is used to advertise resources support. However from looking at the code, only serverInfo is captured from the initialize result, and the rest of the initialize response (which includes resources support) is thrown away.

That's a great point (and not something I was looking at in my changes). Capabilities presumably hasn't been an issue up until this point because MCP was only used for tools in Pydantic AI and it was assumed you wouldn't use an MCP server unless it had tool capabilities.

I'm not quite sure the right way to support this. The upstream mcp library has types for each capability, but replicating/proxying these as native types seems quite verbose.

There is an existing pattern, which is how server_info() is handled (as you point out @phemmer), where the details are stored/cached at initalize time and exposed like so:

@property
    def server_info(self) -> mcp_types.Implementation:
        """Access the information send by the MCP server during initialization."""
        if getattr(self, '_server_info', None) is None:
            raise AttributeError(
                f'The `{self.__class__.__name__}.server_info` is only instantiated after initialization.'
            )
        return self._server_info

We could follow this pattern and just return mcp SDK types. Alternatively, since we don't support listChanged anyway, could we maybe just make them a set of ServerCapabilities literals?

Maybe something like:

ServerCapabilities = Literal[
      "experimental",
      "logging",
      "prompts",
      "resources",
      "tools",
      "completions"
  ]

# MCPServer
@property
def capabilities() -> set[ServerCapabilities]:
    ...

Meh, I guess that's kind ugly: if "resources" in server.capabilities(): ... - perhaps the way mcp already works is superior, so we'd end up with something more like: if server.capabilities.resources: ...? I'm not familiar enough to know what would be more idiomatic for Pydantic.

I don't want this PR to drag on any longer than it already has, but this seems like a reasonable/incremental change overall?

What do you think @DouweM / @Kludex ?

@fennb
Copy link
Author

fennb commented Oct 12, 2025

I'm not sure if addressing this is even in scope of this PR, as ResourceLink handling is also bad, as it automatically fetches the content instead of providing the link. But issue #3099 addresses this.

Yep yep - do you mind moving this topic across to #3099 ?

@DouweM
Copy link
Collaborator

DouweM commented Oct 13, 2025

So I'm not sure if this EmbeddedResource metadata loss issue goes here, there, or in a new issue entirely.

@phemmer We have an issue for that on already: #2288. Input on what the better behavior would be is much appreciated!

@DouweM
Copy link
Collaborator

DouweM commented Oct 13, 2025

Meh, I guess that's kind ugly: if "resources" in server.capabilities(): ...

@fennb I don't mind it! @Kludex You?

@Kludex
Copy link
Member

Kludex commented Oct 13, 2025

What happens when you try to retrieve resources from a server that doesn't have that capability?

@fennb
Copy link
Author

fennb commented Oct 14, 2025

What happens when you try to retrieve resources from a server that doesn't have that capability?

Right now (current state) it would raise an mcp.shared.exceptions.McpError (ie: MCP SDK class error), with the specifics of the error being dependent on server itself.

Arguably we could choose to intercept this and fail-early if the server doesn't have the capability, but I'm not sure it really adds any value? There's also not much of a pattern in the codebase for this that I can tell (given how lightweight exceptions.py is).

I think we can just leave it up to the client application/user to check which server capabilities exist via something like a capabilities() property before they try to use them (particularly given Resources are supposed to be application controlled, not model controlled), yes?

@DouweM
Copy link
Collaborator

DouweM commented Oct 15, 2025

Since "resources are not supported" or "the requested resource doesn't exist" are such common cases users will typically need to handle, I think they should be our own exceptions or the methods could just return [] / None. I'm fine with that as well, you @Kludex?

@fennb
Copy link
Author

fennb commented Oct 16, 2025

Since "resources are not supported" or "the requested resource doesn't exist" are such common cases users will typically need to handle, I think they should be our own exceptions or the methods could just return [] / None. I'm fine with that as well, you @Kludex?

I've done some additional testing. The specifics of errors are pretty server-implementation specific. For example, the official python-sdk's FastMCP server doesn't even have the ability to disable Resource capability advertisement and just returns [] if you call list_resources() on it if none are defined.

read_resource() on a non-existent resource raises an McpError with error = ErrorData(code=0, message='Unknown resource: not://here', data=None). Amusingly, this appears to violate the MCP spec, which says that a not-found should be code -32002.

I think returning None for list_resources() if there's no support makes sense/is simplest.

For other errors in things like read_resource() (ultimately McpError), I guess it does make sense to raise native exception types (same as returning our own data types).

I'll take a pass at this direction to review.

@Kludex
Copy link
Member

Kludex commented Oct 17, 2025

I'm fine with that.

@fennb
Copy link
Author

fennb commented Oct 31, 2025

Just a head up, I haven't abandoned this, just gotten caught up in my day job. Will finish this up ASAP.

Apologies for the delay.

)
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 ''
Copy link
Author

Choose a reason for hiding this comment

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

This is a bit weird. The only two options here are to return empty string or raise an exception. I chose the former as the lesser of two evils, given the real fix would be addressed as part of #3099.

This problem has always existed, it was just less obvious without the underlying type safety.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it should probably be an error, because this is the MCP server itself returning a (link to a) resource that doesn't actually exist. That's a server bug that I wouldn't want to hide

Copy link
Author

Choose a reason for hiding this comment

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

Ok, I've changed this here.

I know this still looks a bit weird, but the behaviour is now consistent with how the code currently works in main (ie: any exceptions during read_resource() just get raised). The only difference is now it raises a native McpError type rather than the python mcp-sdk one.

I suspect we can't really fix this "properly" until we tackle #3099 as the entire concept of fetching a resource inline as part of a tool call result is perhaps a bit fraught in general?

What we have here is the "smallest" change that's keeps things (mostly) consistent.

@fennb fennb changed the title Feature: Minimal MCP client Resources support Feature: MCP client Resources support Nov 3, 2025
@fennb
Copy link
Author

fennb commented Nov 3, 2025

@DouweM this is ready for re-review (apologies for the delay)!

@fennb fennb requested a review from DouweM November 3, 2025 04:44
)
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 ''
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it should probably be an error, because this is the MCP server itself returning a (link to a) resource that doesn't actually exist. That's a server bug that I wouldn't want to hide

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

Choose a reason for hiding this comment

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

That's a bit annoying and something they should fix... How would you feel about testing for "Unknown resource" in the error message so we can ensure consistent behavior with different common MCP servers?

Copy link
Author

@fennb fennb Nov 5, 2025

Choose a reason for hiding this comment

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

I looked into this further. It's even more annoying than it looked and I ended up lodging (at least an initial) issue on the upstream SDK: modelcontextprotocol/python-sdk#1579

Unfortunately (as per that issue), neither the python nor the typescript SDKs follow the documented spec, so we can either:

  1. Try to implement special case handling for each broken upstream SDK server implementation;
  2. Give up and declare it broken upstream and just raise errors for users to deal with for their use case;

I'm the first to admit that neither option is great here. Presumably the server SDKs will be fixed at some point.

My instinct is that (for the moment) we should just raise exceptions if the server raises an unknown exception and it's up to the user to handle. Happy to follow your guidance on preferred path though!

I have, however, at least neatened up the test naming and logic here so it's more clear what's going on.

@fennb fennb requested a review from DouweM November 5, 2025 11:07
@fennb fennb requested a review from Kludex November 6, 2025 02:03
@fennb
Copy link
Author

fennb commented Nov 6, 2025

This is ready for re-review. I noticed we're consistently getting test failures on a specific subset of test suites, but they really seem unrelated to these changes as far as I can see? Example here.

Comment on lines +17 to +18
if TYPE_CHECKING:
from .mcp import Resource, ResourceTemplate, ServerCapabilities
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 makes more sense to define those here.

There's a weird local import on most of the functions in this file.

Copy link
Member

Choose a reason for hiding this comment

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

Actually... Do the methods here need to be functions here?

Since they are just transformations, can't they live in the dataclasses itself? e.g. instead of map_from_mcp_resource, you could do Resource.from_mcp_resource(mcp_resource).

Copy link
Author

@fennb fennb Nov 6, 2025

Choose a reason for hiding this comment

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

Yep, totally. I originally implemented them like so mostly as that was (roughly) the pattern that already existed in that file but I actually prefer Resource.from_mcp_resource(mcp_resource) too. Will change.


@dataclass(repr=False, kw_only=True)
class ResourceAnnotations:
"""Additional properties describing MCP entities."""
Copy link
Member

Choose a reason for hiding this comment

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

Can we please have a link reference in the docstring so we can easily check it in the future?

Suggested change
"""Additional properties describing MCP entities."""
"""Additional properties describing MCP entities.
See the [resource annotations in the MCP specification](link).
"""

Or something like that.

Comment on lines +83 to +95
@classmethod
def from_mcp_sdk_error(cls, error: mcp_exceptions.McpError) -> 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)
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need to do this?

Copy link
Author

Choose a reason for hiding this comment

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

Just because we're returning our own/native MCPError type, rather than the mcp-sdk one and we're flattening it out. The MCP SDK error type has a nested error element as it exactly mirrors the protocol JSON, whilst ours is flattened out.

Apologies if I've misinterpreted your question 🙂.

Copy link
Member

Choose a reason for hiding this comment

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

I'm still not sure this is necessary, since the developer has access to the exception cause, since we are doing raise ... from e.

Do we have a test demonstrates how useful this would be?

try:
result = await self._client.list_resources()
except mcp_exceptions.McpError as e:
raise MCPError.from_mcp_sdk_error(e) from e
Copy link
Member

Choose a reason for hiding this comment

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

Can we have a test on the error output?

Also, on our side, I think we should be more specific on the errors, MCPError should be the parent of the other errors, e.g. in this case ResourceNotFound(MCPError) or ResourceNotSupported(MCPError) (?).

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, this is broadly how I originally implemented this, however, because we went the return [] | None route they got removed (more on this in following comment).

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
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 we can explain a bit more here...

Suggested change
# As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling
# As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling
# `-32002` defines "resource not found".

Copy link
Member

Choose a reason for hiding this comment

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

I disagree with @DouweM regarding raising an error vs returning None.

In this method, we can already see that None means different things:

  1. resource is not supported by the server.
  2. resource not found.
  3. no content in resource.

Copy link
Member

Choose a reason for hiding this comment

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

Also, I think we can leverage the exceptions on the future exception handler mechanism.

Copy link
Author

@fennb fennb Nov 6, 2025

Choose a reason for hiding this comment

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

I disagree with @DouweM regarding raising an error vs returning None.

Yeah, I can see arguments in both directions. FWIW, I think returning [] or None when the server doesn't have resource capabilities is reasonable, ie: list_resources() returning [] is materially the same and if you can't get any resource URIs, then you can't really call read_resource() anyway. I'm not sure raising an exception here adds much value for the user.

If a client really needs to know if a server supports resources (maybe to display UI elements or something) you can still access mcp_server.capabilities.

On returning None vs a ResourceNotFound - This is trickier. One related issue we have is that so far, none of the main reference MCP server implementations (typescript, python) actually implement the protocol spec correctly anyway (though it looks like a python fix might be on the way), which means it's hard to detect a real "not found".

FWIW 2, our closest related precedence for this is probably direct_tool_call(), however, it raises ModelRetry regardless of the actual underlying error, which arguably is also not correct if a user is calling it directly?

Maybe the right model for this is (roughly) how HTTP error codes are commonly handled? Rather than having different subclasses (ie: ResourceNotFound, ServerError, etc) we raise a generic error (maybe just MCPError), which has a code and a message and let users handle flow based on the specific codes they care about (much like users would with HTTP response codes 400-599)? ie:

try:
    data = server.read_resource('url://here')
except MCPError as e:
    print(f'An error occurred reading your resource: {e.message} (err: {e.code})')

Maybe even define some useful common code maps?

RESOURCE_NOT_FOUND = -32002
INVALID_REQUEST = -32600

@Kludex you would have a better sense than I on this sort of thing 😄

Re:

Also, I think we can leverage the exceptions on the future exception handler mechanism.

Sorry, not quite sure what you mean by this - could you clarify?

Copy link
Author

Choose a reason for hiding this comment

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

Just when you thought this couldn't get even more annoying, I just stumbled on this: modelcontextprotocol/modelcontextprotocol#1545

It turns out the spec itself is wrong/has a typo ☹️. From my read, it appears it's currently undecided on whether they're going to fix it or not (in the current protocol version or otherwise).

This is probably a vote in the direction of raising errors with codes and letting clients work out what they want to do with them?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm happy to defer to @Kludex's judgment when it comes to errors vs empty results etc, for what it's worth.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants