-
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 all commits
4bd79c9
bb1c256
98412c0
5c3f58f
73ba0a1
0e5ae00
ad73591
dfe81b6
fd806f7
d99be83
f8feb5b
39d47b5
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 | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -255,11 +255,79 @@ 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 | ||||||||||||||||
| return ( | ||||||||||||||||
| messages.ToolReturn(return_value=structured['result'], metadata=result.meta) | ||||||||||||||||
| if result.meta | ||||||||||||||||
| else structured['result'] | ||||||||||||||||
| ) | ||||||||||||||||
| return messages.ToolReturn(return_value=structured, metadata=result.meta) if result.meta else structured | ||||||||||||||||
|
|
||||||||||||||||
| mapped = [await self._map_tool_result_part(part) for part in result.content] | ||||||||||||||||
| return mapped[0] if len(mapped) == 1 else mapped | ||||||||||||||||
| mapped_part_metadata_tuple_list = [await self._map_tool_result_part(part) for part in result.content] | ||||||||||||||||
|
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. Let's call this |
||||||||||||||||
| if ( | ||||||||||||||||
| all(mapped_part_metadata_tuple[1] is None for mapped_part_metadata_tuple in mapped_part_metadata_tuple_list) | ||||||||||||||||
|
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. I think this can be Also let's move this to a |
||||||||||||||||
| and result.meta is None | ||||||||||||||||
| ): | ||||||||||||||||
| # There is no metadata in the tool result or its parts, return just the mapped values | ||||||||||||||||
| return ( | ||||||||||||||||
| mapped_part_metadata_tuple_list[0][0] | ||||||||||||||||
| if len(mapped_part_metadata_tuple_list[0]) == 1 | ||||||||||||||||
|
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. Shouldn't this be
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. I really don't like parts = [part for (part, _) in parts_and_metadata]
# use parts as we did before |
||||||||||||||||
| else [mapped_part_metadata_tuple[0] for mapped_part_metadata_tuple in mapped_part_metadata_tuple_list] | ||||||||||||||||
| ) | ||||||||||||||||
| elif ( | ||||||||||||||||
| all(mapped_part_metadata_tuple[1] is None for mapped_part_metadata_tuple in mapped_part_metadata_tuple_list) | ||||||||||||||||
| and result.meta is not None | ||||||||||||||||
|
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.
|
||||||||||||||||
| ): | ||||||||||||||||
| # There is no metadata in the tool result parts, but there is metadata in the tool result | ||||||||||||||||
| return messages.ToolReturn( | ||||||||||||||||
| return_value=( | ||||||||||||||||
| mapped_part_metadata_tuple_list[0][0] | ||||||||||||||||
| if len(mapped_part_metadata_tuple_list[0]) == 1 | ||||||||||||||||
| else [ | ||||||||||||||||
| mapped_part_metadata_tuple[0] for mapped_part_metadata_tuple in mapped_part_metadata_tuple_list | ||||||||||||||||
| ] | ||||||||||||||||
|
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. Same comment as up; and we can likely also deduplicate this into a single |
||||||||||||||||
| ), | ||||||||||||||||
| metadata=result.meta, | ||||||||||||||||
| ) | ||||||||||||||||
| else: | ||||||||||||||||
| # There is metadata in the tool result parts and there may be a metadata in the tool result, return a ToolReturn object | ||||||||||||||||
| return_values: list[Any] = [] | ||||||||||||||||
| user_contents: list[Any] = [] | ||||||||||||||||
| return_metadata: dict[str, Any] = {} | ||||||||||||||||
| for idx, (mapped_part, part_metadata) in enumerate(mapped_part_metadata_tuple_list): | ||||||||||||||||
| if part_metadata is not None: | ||||||||||||||||
| # Merge the metadata dictionaries, with part metadata taking precedence | ||||||||||||||||
| if return_metadata.get('content', None) is None: | ||||||||||||||||
| # Create an empty list if it doesn't exist yet | ||||||||||||||||
| return_metadata['content'] = list[dict[str, Any]]() | ||||||||||||||||
|
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. We can likely use |
||||||||||||||||
| return_metadata['content'].append({str(idx): part_metadata}) | ||||||||||||||||
|
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. A list of one-item dicts feels a bit awkward to me; it makes it a lot harder to find the metadata for a specific item then if this were a dict with int indexes or a list with holes. |
||||||||||||||||
| if isinstance(mapped_part, messages.BinaryContent): | ||||||||||||||||
| identifier = mapped_part.identifier | ||||||||||||||||
|
|
||||||||||||||||
| return_values.append(f'See file {identifier}') | ||||||||||||||||
| user_contents.append([f'This is file {identifier}:', mapped_part]) | ||||||||||||||||
| else: | ||||||||||||||||
| user_contents.append(mapped_part) | ||||||||||||||||
|
Comment on lines
+303
to
+309
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. Please put in a comment that this should be kept up to date with this code: pydantic-ai/pydantic_ai_slim/pydantic_ai/_agent_graph.py Lines 1078 to 1084 in 53962e5
And put a comment there pointing back at this code. I'd like to deduplicate it, but I have some more work to do in this area for #3253 so it'll be easier to just come up with a cleaner approach then. |
||||||||||||||||
|
|
||||||||||||||||
| if result.meta is not None and return_metadata.get('content', None) is not None: | ||||||||||||||||
|
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. Let's not store things in |
||||||||||||||||
| # Merge the tool result metadata into the return metadata, with part metadata taking precedence | ||||||||||||||||
| return_metadata['result'] = result.meta | ||||||||||||||||
| elif result.meta is not None and return_metadata.get('content', None) is None: | ||||||||||||||||
| return_metadata = result.meta | ||||||||||||||||
| elif ( | ||||||||||||||||
| result.meta is None | ||||||||||||||||
| and return_metadata.get('content', None) is not None | ||||||||||||||||
| and len(return_metadata['content']) == 1 | ||||||||||||||||
| ): | ||||||||||||||||
| # If there is only one content metadata, unwrap it | ||||||||||||||||
| return_metadata = return_metadata['content'][0] | ||||||||||||||||
| # TODO: What else should we cover here? | ||||||||||||||||
|
|
||||||||||||||||
| # Finally, construct and return the ToolReturn object | ||||||||||||||||
| return messages.ToolReturn( | ||||||||||||||||
| return_value=return_values, | ||||||||||||||||
| content=user_contents, | ||||||||||||||||
| metadata=return_metadata, | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| async def call_tool( | ||||||||||||||||
| self, | ||||||||||||||||
|
|
@@ -374,35 +442,32 @@ async def _sampling_callback( | |||||||||||||||
|
|
||||||||||||||||
| async def _map_tool_result_part( | ||||||||||||||||
| self, part: mcp_types.ContentBlock | ||||||||||||||||
| ) -> str | messages.BinaryContent | dict[str, Any] | list[Any]: | ||||||||||||||||
| ) -> tuple[str | messages.BinaryContent | dict[str, Any] | list[Any], dict[str, Any] | None]: | ||||||||||||||||
| # See https://github.com/jlowin/fastmcp/blob/main/docs/servers/tools.mdx#return-values | ||||||||||||||||
|
|
||||||||||||||||
| 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), metadata | ||||||||||||||||
| except ValueError: | ||||||||||||||||
| pass | ||||||||||||||||
| return text | ||||||||||||||||
| return text, metadata | ||||||||||||||||
| elif isinstance(part, mcp_types.ImageContent): | ||||||||||||||||
| return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) | ||||||||||||||||
| elif isinstance(part, mcp_types.AudioContent): | ||||||||||||||||
| return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType), metadata | ||||||||||||||||
| elif isinstance(part, mcp_types.AudioContent): # pragma: no cover | ||||||||||||||||
| # 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 | ||||||||||||||||
| return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType), metadata | ||||||||||||||||
| elif isinstance(part, mcp_types.EmbeddedResource): | ||||||||||||||||
| resource = part.resource | ||||||||||||||||
| return self._get_content(resource) | ||||||||||||||||
| return self._get_content(part.resource), 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 |
||||||||||||||||
| 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] | ||||||||||||||||
| ) | ||||||||||||||||
| if len(resource_result.contents) == 1: | ||||||||||||||||
| return self._get_content(resource_result.contents[0]), metadata | ||||||||||||||||
| else: | ||||||||||||||||
| return [self._get_content(resource) for resource in resource_result.contents], metadata | ||||||||||||||||
| else: | ||||||||||||||||
| assert_never(part) | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -875,6 +940,7 @@ 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]] | ||||||||||||||||
|
|
||||||||||||||||
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.
This and the above can be simplified to only build
ToolReturnonce