Skip to content

Conversation

@anirbanbasu
Copy link

@anirbanbasu anirbanbasu commented Nov 4, 2025

Fixes: #3323

  • feat: Added a mechanism to extract metadata from MCP tool call response.
  • feat: Added new MCP tools that attach metadata to MCP TextContent and to dict[str, Any] as a _meta key.
  • test: Added tests that run successfully.
  • TODOs
    • Should really capture metadata from all MCP content types, not just TextContent.
    • Clarify if we are supporting FastMCP metadata or standards _meta in content blocks; or both.
    • Check that coverage is 100%.

feat: Added a new MCP tool that attaches metadata to MCP TextContent.
test: Added a test to call the aforementioned tool (failing as of now).
@anirbanbasu
Copy link
Author

@DouweM this is the PR in response to #3323 -- I am still working on it.

@anirbanbasu
Copy link
Author

Hi @DouweM, quoting you from #3323 (comment), I am pondering over whether it is better to edit the _map_tool_result_part in the MCPServer to handle the multi-modal content or modify the _call_tool method in the _agent_graph.py.

At present, if you take a look at the _map_tool_result_part method in this PR, I have only modified it to include the metadata if it is TextContent. I think I must also add the metadata if the response has multi-modal content. So, perhaps, changing this method to handle the multi-modal content is better instead of editing the _call_tool method.

todo: Exhaustive tests to improve coverage.
@anirbanbasu
Copy link
Author

Quoting myself (#3339 (comment))

[...] perhaps, changing this [i.e., _map_tool_result_part ] method to handle the multi-modal content is better instead of editing the _call_tool method

Note that I just did that @DouweM. Need to add more tests to improve coverage.

@anirbanbasu anirbanbasu marked this pull request as ready for review November 5, 2025 14:06
return structured
if isinstance(structured, dict) and (
(len(structured) == 1 and 'result' in structured)
or (len(structured) == 2 and 'result' in structured and '_meta' in structured)
Copy link
Collaborator

Choose a reason for hiding this comment

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

In the example at https://gofastmcp.com/servers/tools#toolresult-and-metadata, wouldn't the metadata be on result.meta? I don't think we should try to parse it directly from the result.structuredData

Copy link
Author

Choose a reason for hiding this comment

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

I agree with you regarding not modifying the structured result.

We may be, though, not thinking of the same metadata.

What you are referring to is a FastMCP tool call result metadata. In addition, the meta is an attribute of FastMCP ToolResult only from version 2.13.1 (according to https://gofastmcp.com/servers/tools#toolresult-and-metadata) while the version currently in use with Pydantic AI is 2.12.4.

What I have been referring to is the _meta in the latest MCP standard https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta. FastMCP wraps this in TextContent and other types of content too.

If we go with the FastMCP-specific meta then there is a possibility that a MCP server implemented without using that specific version (> 2.13.1) of FastMCP or implemented in a different language will not return the metadata in the expected ToolResult style object.

Having said that, there is a possibility that FastMCP is implementing what the upcoming MCP standard will be, as they seem to typically do. (I haven't dug through this in details.)

In summary:

  1. for structured content, I think we could go with meta of ToolResult but I need to upgrade FastMCP for Pydantic AI to 2.13.1 or above;
  2. however, we ought to support _meta aliased meta for each content type.

Regarding (1), is this something I should do by myself?

What are your thoughts?

Copy link
Author

Choose a reason for hiding this comment

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

Actually, I noticed that in my original issue #3323, I had referred to both the FastMCP metadata and the standards _meta. Sorry for the confusion.

Ideally, we should support both.

async def _map_tool_result_part(
self, part: mcp_types.ContentBlock
) -> str | messages.BinaryContent | dict[str, Any] | list[Any]:
) -> str | messages.ToolReturn | messages.BinaryContent | dict[str, Any] | list[Any]:
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not going to work, because now the tool call could return a list of ToolReturns which is not supported: the tool needs to itself return a ToolReturn object.

I think we should build the list of output contents as we used to, and then if there's result.meta, return a ToolReturn with that metadata + the output content, instead of returning the output content directly

Copy link
Author

Choose a reason for hiding this comment

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

Okay, but as I mentioned in my comment above, what I have been referring to is the _meta in the latest MCP standard https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta. This can be present in each content block, it seems.

If we return a single meta, there is no way to know how to merge multiple _meta that may be present in the content blocks.

# See https://github.com/jlowin/fastmcp/blob/main/docs/servers/tools.mdx#return-values

# Let's also check for metadata but it can be present in not just TextContent
metadata: dict[str, Any] | None = part.meta
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we want to get the metadata off of the ContentBlock, or from the CallToolResult itself? I was assuming this PR was going to be about tool call result metadata as in https://gofastmcp.com/servers/tools#toolresult-and-metadata, not about metadata on specific text/binary parts.

Note that we have a separate issue about embedded resource metadata: #2288 (comment).

Copy link
Author

Choose a reason for hiding this comment

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

I see your point in #2288 (comment) -- the same applies to tool calls.

However, maybe, we still should not ignore the _meta in content blocks because maybe the Pydantic AI user has some use of them (other than passing these to the LLM), such as logging.

💡 That makes me think that maybe there is a need to support a user-specified MCP tool/feature call handler, using which, the user could figure out a way to handle tool call in a way they want while Pydantic AI's default handler will ignore _meta but support FastMCP ToolResult.meta.

What do you think @DouweM?

@DouweM
Copy link
Collaborator

DouweM commented Nov 7, 2025

@anirbanbasu I'll respond here at the top level with some thoughts because the 3 threads touch on overlapping topics:

  1. There should be a way to access both tool-result metadata and tool-result-item metadata
  2. MCP supports _meta on every object, so result.meta is not FastMCP specific, it's right in the MCP SDK: CallToolResult inherits from Result which has a meta field corresponding to _meta.
    1. I think this is the natural place to read "tool-result metadata" from (and for any MCP server SDK to write metadata)
    2. I don't think we should ever try to extract metadata from result.structuredData, as we should treat it as arbitrary data
  3. If there is only tool-result metadata and no tool-result-item metadata, MCPServer.direct_call_tool should return it as ToolReturn.metadata
  4. If there is no tool-result metadata, but there is a single tool-result-item (TextContent etc) that does have .meta, that should be the ToolReturn.metadata
  5. If there are multiple pieces of metadata, on the tool-result and/or on one or more tool-result-items, we have a couple of options. Since ToolReturn.content will hold the tool-result-items in this case, perhaps ToolReturn.metadata should be a dictionary like {'result': <result metadata>, 'content': [<metadata for content index 0>, ...]]}
    1. Another option is to leverage the existing process_tool_call hook, but (unfortunately?) it gets the processed tool result rather than the raw thing. Perhaps it could pass a flag into direct_call_tool to return the raw mcp.types.CallToolResult so metadata can be read off of it?

Let me know what you think. The fact that there can be metadata at multiple levels in MCP but not currently in Pydantic AI makes this tricky!

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.

Capture MCP tool invocation response metadata

2 participants