Skip to content

Commit 39d47b5

Browse files
committed
experimental: Potential implementation of 3, 4 and 5 described in #3339 (comment)
1 parent f8feb5b commit 39d47b5

File tree

1 file changed

+79
-77
lines changed
  • pydantic_ai_slim/pydantic_ai

1 file changed

+79
-77
lines changed

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 79 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -254,23 +254,80 @@ async def direct_call_tool(
254254
):
255255
# 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.
256256
# See https://github.com/modelcontextprotocol/python-sdk#structured-output
257-
if isinstance(structured, dict) and (
258-
(len(structured) == 1 and 'result' in structured)
259-
or (len(structured) == 2 and 'result' in structured and '_meta' in structured)
260-
):
257+
if isinstance(structured, dict) and len(structured) == 1 and 'result' in structured:
261258
return (
262-
messages.ToolReturn(return_value=structured['result'], metadata=structured['_meta'])
263-
if structured.get('_meta', None) is not None
259+
messages.ToolReturn(return_value=structured['result'], metadata=result.meta)
260+
if result.meta
264261
else structured['result']
265262
)
263+
return messages.ToolReturn(return_value=structured, metadata=result.meta) if result.meta else structured
264+
265+
mapped_part_metadata_tuple_list = [await self._map_tool_result_part(part) for part in result.content]
266+
if (
267+
all(mapped_part_metadata_tuple[1] is None for mapped_part_metadata_tuple in mapped_part_metadata_tuple_list)
268+
and result.meta is None
269+
):
270+
# There is no metadata in the tool result or its parts, return just the mapped values
266271
return (
267-
messages.ToolReturn(return_value=structured, metadata=structured['_meta'])
268-
if structured.get('_meta', None) is not None
269-
else structured
272+
mapped_part_metadata_tuple_list[0][0]
273+
if len(mapped_part_metadata_tuple_list[0]) == 1
274+
else [mapped_part_metadata_tuple[0] for mapped_part_metadata_tuple in mapped_part_metadata_tuple_list]
275+
)
276+
elif (
277+
all(mapped_part_metadata_tuple[1] is None for mapped_part_metadata_tuple in mapped_part_metadata_tuple_list)
278+
and result.meta is not None
279+
):
280+
# There is no metadata in the tool result parts, but there is metadata in the tool result
281+
return messages.ToolReturn(
282+
return_value=(
283+
mapped_part_metadata_tuple_list[0][0]
284+
if len(mapped_part_metadata_tuple_list[0]) == 1
285+
else [
286+
mapped_part_metadata_tuple[0] for mapped_part_metadata_tuple in mapped_part_metadata_tuple_list
287+
]
288+
),
289+
metadata=result.meta,
290+
)
291+
else:
292+
# There is metadata in the tool result parts and there may be a metadata in the tool result, return a ToolReturn object
293+
return_values: list[Any] = []
294+
user_contents: list[Any] = []
295+
return_metadata: dict[str, Any] = {}
296+
for idx, (mapped_part, part_metadata) in enumerate(mapped_part_metadata_tuple_list):
297+
if part_metadata is not None:
298+
# Merge the metadata dictionaries, with part metadata taking precedence
299+
if return_metadata.get('content', None) is None:
300+
# Create an empty list if it doesn't exist yet
301+
return_metadata['content'] = list[dict[str, Any]]()
302+
return_metadata['content'].append({str(idx): part_metadata})
303+
if isinstance(mapped_part, messages.BinaryContent):
304+
identifier = mapped_part.identifier
305+
306+
return_values.append(f'See file {identifier}')
307+
user_contents.append([f'This is file {identifier}:', mapped_part])
308+
else:
309+
user_contents.append(mapped_part)
310+
311+
if result.meta is not None and return_metadata.get('content', None) is not None:
312+
# Merge the tool result metadata into the return metadata, with part metadata taking precedence
313+
return_metadata['result'] = result.meta
314+
elif result.meta is not None and return_metadata.get('content', None) is None:
315+
return_metadata = result.meta
316+
elif (
317+
result.meta is None
318+
and return_metadata.get('content', None) is not None
319+
and len(return_metadata['content']) == 1
320+
):
321+
# If there is only one content metadata, unwrap it
322+
return_metadata = return_metadata['content'][0]
323+
# TODO: What else should we cover here?
324+
325+
# Finally, construct and return the ToolReturn object
326+
return messages.ToolReturn(
327+
return_value=return_values,
328+
content=user_contents,
329+
metadata=return_metadata,
270330
)
271-
272-
mapped = [await self._map_tool_result_part(part) for part in result.content]
273-
return mapped[0] if len(mapped) == 1 else mapped
274331

275332
async def call_tool(
276333
self,
@@ -385,87 +442,32 @@ async def _sampling_callback(
385442

386443
async def _map_tool_result_part(
387444
self, part: mcp_types.ContentBlock
388-
) -> str | messages.ToolReturn | messages.BinaryContent | dict[str, Any] | list[Any]:
445+
) -> tuple[str | messages.BinaryContent | dict[str, Any] | list[Any], dict[str, Any] | None]:
389446
# See https://github.com/jlowin/fastmcp/blob/main/docs/servers/tools.mdx#return-values
390447

391-
# Let's also check for metadata but it can be present in not just TextContent
392448
metadata: dict[str, Any] | None = part.meta
393449
if isinstance(part, mcp_types.TextContent):
394450
text = part.text
395451
if text.startswith(('[', '{')):
396452
try:
397-
return (
398-
pydantic_core.from_json(text)
399-
if metadata is None
400-
else messages.ToolReturn(return_value=pydantic_core.from_json(text), metadata=metadata)
401-
)
453+
return pydantic_core.from_json(text), metadata
402454
except ValueError:
403455
pass
404-
return text if metadata is None else messages.ToolReturn(return_value=text, metadata=metadata)
456+
return text, metadata
405457
elif isinstance(part, mcp_types.ImageContent):
406-
binary_response = messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType)
407-
return (
408-
binary_response
409-
if metadata is None
410-
else messages.ToolReturn(
411-
return_value=f'See file {binary_response.identifier}',
412-
content=[f'This is file {binary_response.identifier}:', binary_response],
413-
metadata=metadata,
414-
)
415-
)
416-
elif isinstance(part, mcp_types.AudioContent):
458+
return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType), metadata
459+
elif isinstance(part, mcp_types.AudioContent): # pragma: no cover
417460
# NOTE: The FastMCP server doesn't support audio content.
418461
# See <https://github.com/modelcontextprotocol/python-sdk/issues/952> for more details.
419-
binary_response = messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType)
420-
return ( # pragma: no cover
421-
binary_response
422-
if metadata is None
423-
else messages.ToolReturn(
424-
return_value=f'See file {binary_response.identifier}',
425-
content=[f'This is file {binary_response.identifier}:', binary_response],
426-
metadata=metadata,
427-
)
428-
)
462+
return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType), metadata
429463
elif isinstance(part, mcp_types.EmbeddedResource):
430-
resource = part.resource
431-
response = self._get_content(resource)
432-
return (
433-
response
434-
if metadata is None
435-
else messages.ToolReturn(
436-
return_value=response if isinstance(response, str) else f'See file {response.identifier}',
437-
content=None if isinstance(response, str) else [f'This is file {response.identifier}:', response],
438-
metadata=metadata,
439-
)
440-
)
464+
return self._get_content(part.resource), metadata
441465
elif isinstance(part, mcp_types.ResourceLink):
442466
resource_result: mcp_types.ReadResourceResult = await self._client.read_resource(part.uri)
443467
if len(resource_result.contents) == 1:
444-
response = self._get_content(resource_result.contents[0])
445-
return (
446-
response
447-
if metadata is None
448-
else messages.ToolReturn(
449-
return_value=response if isinstance(response, str) else f'See file {response.identifier}',
450-
content=None
451-
if isinstance(response, str)
452-
else [f'This is file {response.identifier}:', response],
453-
metadata=metadata,
454-
)
455-
)
468+
return self._get_content(resource_result.contents[0]), metadata
456469
else:
457-
responses = [self._get_content(resource) for resource in resource_result.contents] # pragma: no cover
458-
return [ # pragma: no cover
459-
response
460-
if isinstance(response, str)
461-
else messages.ToolReturn(
462-
return_value=response if isinstance(response, str) else f'See file {response.identifier}',
463-
content=None
464-
if isinstance(response, str)
465-
else [f'This is file {response.identifier}:', response],
466-
)
467-
for response in responses
468-
]
470+
return [self._get_content(resource) for resource in resource_result.contents], metadata
469471
else:
470472
assert_never(part)
471473

@@ -941,7 +943,7 @@ def __eq__(self, value: object, /) -> bool:
941943
| messages.ToolReturn
942944
| dict[str, Any]
943945
| list[Any]
944-
| Sequence[str | messages.BinaryContent | messages.ToolReturn | dict[str, Any] | list[Any]]
946+
| Sequence[str | messages.BinaryContent | dict[str, Any] | list[Any]]
945947
)
946948
"""The result type of an MCP tool call."""
947949

0 commit comments

Comments
 (0)