Skip to content

Commit ad73591

Browse files
committed
chore: Improved metadata parsing for both structured content and TextContent.
1 parent 0e5ae00 commit ad73591

File tree

3 files changed

+52
-25
lines changed

3 files changed

+52
-25
lines changed

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,6 @@ async def direct_call_tool(
270270
)
271271

272272
mapped = [await self._map_tool_result_part(part) for part in result.content]
273-
if result.meta:
274-
return (
275-
messages.ToolReturn(return_value=mapped[0], metadata=result.meta)
276-
if len(mapped) == 1
277-
else [messages.ToolReturn(return_value=mapped_item, metadata=result.meta) for mapped_item in mapped]
278-
)
279273
return mapped[0] if len(mapped) == 1 else mapped
280274

281275
async def call_tool(
@@ -391,17 +385,23 @@ async def _sampling_callback(
391385

392386
async def _map_tool_result_part(
393387
self, part: mcp_types.ContentBlock
394-
) -> str | messages.BinaryContent | dict[str, Any] | list[Any]:
388+
) -> str | messages.ToolReturn | messages.BinaryContent | dict[str, Any] | list[Any]:
395389
# See https://github.com/jlowin/fastmcp/blob/main/docs/servers/tools.mdx#return-values
396390

391+
# Let's also check for metadata but it can be present in not just TextContent
392+
metadata: dict[str, Any] | None = part.meta
397393
if isinstance(part, mcp_types.TextContent):
398394
text = part.text
399395
if text.startswith(('[', '{')):
400396
try:
401-
return pydantic_core.from_json(text)
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+
)
402402
except ValueError:
403403
pass
404-
return text
404+
return text if metadata is None else messages.ToolReturn(return_value=text, metadata=metadata)
405405
elif isinstance(part, mcp_types.ImageContent):
406406
return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType)
407407
elif isinstance(part, mcp_types.AudioContent):

tests/mcp_server.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,20 @@ async def get_image_resource_link() -> ResourceLink:
6868
)
6969

7070

71-
@mcp.tool(annotations=ToolAnnotations(title='Collatz Conjecture sequence generator'))
72-
async def collatz_conjecture(n: int) -> dict[str, Any]:
71+
@mcp.tool(structured_output=False, annotations=ToolAnnotations(title='Collatz Conjecture sequence generator'))
72+
async def get_collatz_conjecture(n: int) -> TextContent:
7373
"""Generate the Collatz conjecture sequence for a given number.
7474
This tool attaches response metadata.
7575
7676
Args:
7777
n: The starting number for the Collatz sequence.
7878
Returns:
79-
A list representing the Collatz sequence.
79+
A list representing the Collatz sequence with attached metadata.
8080
"""
8181
if n <= 0:
82-
raise ValueError('Startig number for the Collatz conjecture must be a positive integer.')
82+
raise ValueError('Starting number for the Collatz conjecture must be a positive integer.')
83+
84+
input_param_n = n # store the original input value
8385

8486
sequence = [n]
8587
while n != 1:
@@ -88,10 +90,21 @@ async def collatz_conjecture(n: int) -> dict[str, Any]:
8890
else:
8991
n = 3 * n + 1
9092
sequence.append(n)
91-
response: dict[str, Any] = {'result': str(sequence)}
92-
# attach metadata to the response
93-
response['_meta'] = {'pydantic_ai': {'tool': 'collatz_conjecture', 'length': len(sequence)}}
94-
return response
93+
94+
return TextContent(
95+
type='text',
96+
text=str(sequence),
97+
_meta={'pydantic_ai': {'tool': 'collatz_conjecture', 'n': input_param_n, 'length': len(sequence)}},
98+
)
99+
100+
101+
@mcp.tool()
102+
async def get_structured_text_content_with_metadata() -> dict[str, Any]:
103+
"""Return structured dict with metadata."""
104+
return {
105+
'result': 'This is some text content.',
106+
'_meta': {'pydantic_ai': {'source': 'get_structured_text_content_with_metadata'}},
107+
}
95108

96109

97110
@mcp.resource('resource://kiwi.png', mime_type='image/png')

tests/test_mcp.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ async def test_stdio_server(run_context: RunContext[int]):
7878
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
7979
async with server:
8080
tools = [tool.tool_def for tool in (await server.get_tools(run_context)).values()]
81-
assert len(tools) == snapshot(19)
81+
assert len(tools) == snapshot(20)
8282
assert tools[0].name == 'celsius_to_fahrenheit'
8383
assert isinstance(tools[0].description, str)
8484
assert tools[0].description.startswith('Convert Celsius to Fahrenheit.')
@@ -92,16 +92,30 @@ async def test_tool_response_metadata(run_context: RunContext[int]):
9292
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
9393
async with server:
9494
tools = [tool.tool_def for tool in (await server.get_tools(run_context)).values()]
95-
assert len(tools) == snapshot(19)
96-
assert tools[4].name == 'collatz_conjecture'
95+
assert len(tools) == snapshot(20)
96+
assert tools[4].name == 'get_collatz_conjecture'
9797
assert isinstance(tools[4].description, str)
9898
assert tools[4].description.startswith('Generate the Collatz conjecture sequence for a given number.')
9999

100-
# Test calling the Collatz conjecture generator tool
101-
result = await server.direct_call_tool('collatz_conjecture', {'n': 7})
100+
result = await server.direct_call_tool('get_collatz_conjecture', {'n': 7})
102101
assert isinstance(result, ToolReturn)
103-
assert result.return_value == '[7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1]'
104-
assert result.metadata == {'pydantic_ai': {'tool': 'collatz_conjecture', 'length': 17}}
102+
assert result.return_value == snapshot([7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1])
103+
assert result.metadata == snapshot({'pydantic_ai': {'tool': 'collatz_conjecture', 'n': 7, 'length': 17}})
104+
105+
106+
async def test_tool_structured_response_metadata(run_context: RunContext[int]):
107+
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
108+
async with server:
109+
tools = [tool.tool_def for tool in (await server.get_tools(run_context)).values()]
110+
assert len(tools) == snapshot(20)
111+
assert tools[5].name == 'get_structured_text_content_with_metadata'
112+
assert isinstance(tools[5].description, str)
113+
assert tools[5].description.startswith('Return structured dict with metadata.')
114+
115+
result = await server.direct_call_tool('get_structured_text_content_with_metadata', {})
116+
assert isinstance(result, ToolReturn)
117+
assert result.return_value == 'This is some text content.'
118+
assert result.metadata == snapshot({'pydantic_ai': {'source': 'get_structured_text_content_with_metadata'}})
105119

106120

107121
async def test_reentrant_context_manager():
@@ -155,7 +169,7 @@ async def test_stdio_server_with_cwd(run_context: RunContext[int]):
155169
server = MCPServerStdio('python', ['mcp_server.py'], cwd=test_dir)
156170
async with server:
157171
tools = await server.get_tools(run_context)
158-
assert len(tools) == snapshot(19)
172+
assert len(tools) == snapshot(20)
159173

160174

161175
async def test_process_tool_call(run_context: RunContext[int]) -> int:

0 commit comments

Comments
 (0)