-
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
6eea048
f54b127
7935568
026b364
b8ce49c
13fb859
46a7b87
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 |
|---|---|---|
|
|
@@ -263,12 +263,60 @@ 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_value = ( | ||
| structured['result'] | ||
| if isinstance(structured, dict) and len(structured) == 1 and 'result' in structured | ||
| else structured | ||
| ) | ||
| return messages.ToolReturn(return_value=return_value, metadata=result.meta) if result.meta else return_value | ||
|
|
||
| parts_with_metadata = [await self._map_tool_result_part(part) for part in result.content] | ||
| parts_only = [part for part, _ in parts_with_metadata] | ||
| any_part_has_metadata = any(metadata is not None for _, metadata in parts_with_metadata) | ||
| return_values: list[Any] = [] | ||
| user_contents: list[Any] = [] | ||
| parts_metadata: dict[int, dict[str, Any]] = {} | ||
| return_metadata: dict[str, Any] = {} | ||
| if any_part_has_metadata: | ||
| # There is metadata in the tool result parts and there may be a metadata in the tool result, return a ToolReturn object | ||
| for idx, (part, part_metadata) in enumerate(parts_with_metadata): | ||
| if part_metadata is not None: | ||
| parts_metadata[idx] = part_metadata | ||
| # TODO: Keep updated with the multimodal content parsing in _agent_graph.py | ||
| if isinstance(part, messages.BinaryContent): | ||
| identifier = part.identifier | ||
|
|
||
| return_values.append(f'See file {identifier}') | ||
| user_contents.append([f'This is file {identifier}:', part]) | ||
| else: | ||
| user_contents.append(part) | ||
|
|
||
| if len(parts_metadata) > 0: | ||
| if result.meta is not None and len(result.meta) > 0: | ||
| # Merge the tool result metadata and parts metadata into the return metadata | ||
| return_metadata = {'result': result.meta, 'content': parts_metadata} | ||
| else: | ||
| # Only parts metadata exists | ||
| if len(parts_metadata) == 1: | ||
| # If there is only one content metadata, unwrap it | ||
| return_metadata = parts_metadata[0] | ||
| else: | ||
| return_metadata = {'content': parts_metadata} | ||
| else: | ||
| if result.meta is not None and len(result.meta) > 0: | ||
| return_metadata = result.meta | ||
| # TODO: What else should we cover here? | ||
|
|
||
| mapped = [await self._map_tool_result_part(part) for part in result.content] | ||
| return mapped[0] if len(mapped) == 1 else mapped | ||
| # Finally, construct and return the ToolReturn object | ||
| return ( | ||
| messages.ToolReturn( | ||
| return_value=return_values, | ||
| content=user_contents, | ||
| metadata=return_metadata, | ||
| ) | ||
| if len(return_metadata) > 0 | ||
| else (parts_only[0] if len(parts_only) == 1 else parts_only) | ||
| ) | ||
|
|
||
| async def call_tool( | ||
| self, | ||
|
|
@@ -383,35 +431,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 | ||
| 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
Author
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. @DouweM I addressed the rest of your comments but this one. I am thinking of a clean way of getting this done. |
||
| 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) | ||
|
|
||
|
|
@@ -884,6 +929,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]] | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.