-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Added a mechanism to extract metadata from MCP tool call response #3339
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 9 commits
4bd79c9
bb1c256
98412c0
5c3f58f
73ba0a1
0e5ae00
ad73591
dfe81b6
fd806f7
d99be83
f8feb5b
39d47b5
6eea048
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 |
|---|---|---|
|
|
@@ -254,9 +254,20 @@ async def direct_call_tool( | |
| ): | ||
| # The MCP SDK wraps primitives and generic types like list in a `result` key, but we want to use the raw value returned by the tool function. | ||
| # See https://github.com/modelcontextprotocol/python-sdk#structured-output | ||
| if isinstance(structured, dict) and len(structured) == 1 and 'result' in structured: | ||
| return structured['result'] | ||
| 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) | ||
| ): | ||
| return ( | ||
| messages.ToolReturn(return_value=structured['result'], metadata=structured['_meta']) | ||
| if structured.get('_meta', None) is not None | ||
| else structured['result'] | ||
| ) | ||
| return ( | ||
| messages.ToolReturn(return_value=structured, metadata=structured['_meta']) | ||
| if structured.get('_meta', None) is not None | ||
| else structured | ||
| ) | ||
|
|
||
| mapped = [await self._map_tool_result_part(part) for part in result.content] | ||
| return mapped[0] if len(mapped) == 1 else mapped | ||
|
|
@@ -374,35 +385,87 @@ async def _sampling_callback( | |
|
|
||
| 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]: | ||
|
||
| # 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 | ||
DouweM marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if isinstance(part, mcp_types.TextContent): | ||
| text = part.text | ||
| if text.startswith(('[', '{')): | ||
| try: | ||
| return pydantic_core.from_json(text) | ||
| return ( | ||
| pydantic_core.from_json(text) | ||
| if metadata is None | ||
| else messages.ToolReturn(return_value=pydantic_core.from_json(text), metadata=metadata) | ||
| ) | ||
| except ValueError: | ||
| pass | ||
| return text | ||
| return text if metadata is None else messages.ToolReturn(return_value=text, metadata=metadata) | ||
| elif isinstance(part, mcp_types.ImageContent): | ||
| return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) | ||
| binary_response = messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) | ||
| return ( | ||
| binary_response | ||
| if metadata is None | ||
| else messages.ToolReturn( | ||
| return_value=f'See file {binary_response.identifier}', | ||
| content=[f'This is file {binary_response.identifier}:', binary_response], | ||
| metadata=metadata, | ||
| ) | ||
| ) | ||
| elif isinstance(part, mcp_types.AudioContent): | ||
| # NOTE: The FastMCP server doesn't support audio content. | ||
| # See <https://github.com/modelcontextprotocol/python-sdk/issues/952> for more details. | ||
| return messages.BinaryContent( | ||
| data=base64.b64decode(part.data), media_type=part.mimeType | ||
| ) # pragma: no cover | ||
| binary_response = messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) | ||
| return ( # pragma: no cover | ||
| binary_response | ||
| if metadata is None | ||
| else messages.ToolReturn( | ||
| return_value=f'See file {binary_response.identifier}', | ||
| content=[f'This is file {binary_response.identifier}:', binary_response], | ||
| metadata=metadata, | ||
| ) | ||
| ) | ||
| elif isinstance(part, mcp_types.EmbeddedResource): | ||
| 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) | ||
| response = self._get_content(resource) | ||
| 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] | ||
| response | ||
| if metadata is None | ||
| else messages.ToolReturn( | ||
| return_value=response if isinstance(response, str) else f'See file {response.identifier}', | ||
| content=None if isinstance(response, str) else [f'This is file {response.identifier}:', response], | ||
| metadata=metadata, | ||
| ) | ||
| ) | ||
| elif isinstance(part, mcp_types.ResourceLink): | ||
| resource_result: mcp_types.ReadResourceResult = await self._client.read_resource(part.uri) | ||
|
Collaborator
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. Note that |
||
| if len(resource_result.contents) == 1: | ||
| response = self._get_content(resource_result.contents[0]) | ||
| return ( | ||
| response | ||
| if metadata is None | ||
| else messages.ToolReturn( | ||
| return_value=response if isinstance(response, str) else f'See file {response.identifier}', | ||
| content=None | ||
| if isinstance(response, str) | ||
| else [f'This is file {response.identifier}:', response], | ||
| metadata=metadata, | ||
| ) | ||
| ) | ||
| else: | ||
| responses = [self._get_content(resource) for resource in resource_result.contents] # pragma: no cover | ||
| return [ # pragma: no cover | ||
| response | ||
| if isinstance(response, str) | ||
| else messages.ToolReturn( | ||
| return_value=response if isinstance(response, str) else f'See file {response.identifier}', | ||
| content=None | ||
| if isinstance(response, str) | ||
| else [f'This is file {response.identifier}:', response], | ||
| ) | ||
| for response in responses | ||
| ] | ||
| else: | ||
| assert_never(part) | ||
|
|
||
|
|
@@ -875,9 +938,10 @@ def __eq__(self, value: object, /) -> bool: | |
| ToolResult = ( | ||
| str | ||
| | messages.BinaryContent | ||
| | messages.ToolReturn | ||
| | dict[str, Any] | ||
| | list[Any] | ||
| | Sequence[str | messages.BinaryContent | dict[str, Any] | list[Any]] | ||
| | Sequence[str | messages.BinaryContent | messages.ToolReturn | dict[str, Any] | list[Any]] | ||
| ) | ||
| """The result type of an MCP tool call.""" | ||
|
|
||
|
|
||
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.
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 theresult.structuredDataThere 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.
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
metais an attribute of FastMCPToolResultonly 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
_metain the latest MCP standard https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta. FastMCP wraps this inTextContentand other types of content too.If we go with the FastMCP-specific
metathen 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 expectedToolResultstyle 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:
metaofToolResultbut I need to upgrade FastMCP for Pydantic AI to 2.13.1 or above;_metaaliasedmetafor each content type.Regarding (1), is this something I should do by myself?
What are your thoughts?
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.
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.
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.
Fixed this in commit 39d47b5.