Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/mcp/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ calculator_server = MCPServerSSE(
agent = Agent('openai:gpt-4o', toolsets=[weather_server, calculator_server])
```

## Tool `meta`, `annotations` & `output_schema`

MCP tools often include metadata that provides additional information about the tool's characteristics, which can use useful in [`toolset filtering`][pydantic_ai.toolsets.FilteredToolset]. These properties are available in the [`ToolDefinition.metadata`][pydantic_ai.tools.ToolDefinition.metadata].

## Custom TLS / SSL configuration

In some environments you need to tweak how HTTPS connections are established –
Expand Down
5 changes: 5 additions & 0 deletions pydantic_ai_slim/pydantic_ai/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,11 @@ async def get_tools(self, ctx: RunContext[Any]) -> dict[str, ToolsetTool[Any]]:
name=name,
description=mcp_tool.description,
parameters_json_schema=mcp_tool.inputSchema,
metadata={
'meta': mcp_tool.meta,
'annotations': mcp_tool.annotations.model_dump() if mcp_tool.annotations else None,
'output_schema': mcp_tool.outputSchema or None,
},
),
)
for mcp_tool in await self.list_tools()
Expand Down
6 changes: 6 additions & 0 deletions pydantic_ai_slim/pydantic_ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,12 @@ class ToolDefinition:
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
"""

metadata: dict[str, Any] | None = None
"""Tool metadata that can be set by the toolset this tool came from. It is not sent to the model, but can be used for filtering and tool behavior customization.

For MCP tools, this contains the `_meta`, `annotations`, and `output_schema` fields from the tool definition.
"""

@property
def defer(self) -> bool:
"""Whether calls to this tool will be deferred.
Expand Down
3 changes: 2 additions & 1 deletion tests/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
SamplingMessage,
TextContent,
TextResourceContents,
ToolAnnotations,
)
from pydantic import AnyUrl, BaseModel

mcp = FastMCP('Pydantic AI MCP Server')
log_level = 'unset'


@mcp.tool()
@mcp.tool(annotations=ToolAnnotations(title='Celsius to Fahrenheit'))
async def celsius_to_fahrenheit(celsius: float) -> float:
"""Convert Celsius to Fahrenheit.

Expand Down
2 changes: 2 additions & 0 deletions tests/test_logfire.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ async def my_ret(x: int) -> str:
'strict': None,
'sequential': False,
'kind': 'function',
'metadata': None,
}
],
'builtin_tools': [],
Expand Down Expand Up @@ -780,6 +781,7 @@ class MyOutput:
'strict': None,
'sequential': False,
'kind': 'output',
'metadata': None,
}
],
'allow_text_output': False,
Expand Down
16 changes: 16 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,22 @@ async def test_tool_returning_multiple_items(allow_model_requests: None, agent:
)


async def test_tool_metadata_extraction():
"""Test that MCP tool metadata is properly extracted into ToolDefinition."""

server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
async with server:
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
tools = [tool.tool_def for tool in (await server.get_tools(ctx)).values()]
# find `celsius_to_fahrenheit`
celsius_to_fahrenheit = next(tool for tool in tools if tool.name == 'celsius_to_fahrenheit')
assert celsius_to_fahrenheit.metadata is not None
assert celsius_to_fahrenheit.metadata.get('annotations') is not None
assert celsius_to_fahrenheit.metadata.get('annotations', {}).get('title', None) == 'Celsius to Fahrenheit'
assert celsius_to_fahrenheit.metadata.get('output_schema') is not None
assert celsius_to_fahrenheit.metadata.get('output_schema', {}).get('type', None) == 'object'


async def test_client_sampling(run_context: RunContext[int]):
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
server.sampling_model = TestModel(custom_output_text='sampling model response')
Expand Down
17 changes: 17 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def test_docstring_google(docstring_format: Literal['google', 'auto']):
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -179,6 +180,7 @@ def test_docstring_sphinx(docstring_format: Literal['sphinx', 'auto']):
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -220,6 +222,7 @@ def test_docstring_numpy(docstring_format: Literal['numpy', 'auto']):
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -261,6 +264,7 @@ def my_tool(x: int) -> str: # pragma: no cover
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -300,6 +304,7 @@ def my_tool(x: int) -> str: # pragma: no cover
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -345,6 +350,7 @@ def my_tool(x: int) -> str: # pragma: no cover
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -378,6 +384,7 @@ def test_only_returns_type():
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand All @@ -402,6 +409,7 @@ def test_docstring_unknown():
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -444,6 +452,7 @@ def test_docstring_google_no_body(docstring_format: Literal['google', 'auto']):
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -479,6 +488,7 @@ def takes_just_model(model: Foo) -> str:
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -523,6 +533,7 @@ def takes_just_model(model: Foo, z: int) -> str:
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -887,6 +898,7 @@ def test_suppress_griffe_logging(caplog: LogCaptureFixture):
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down Expand Up @@ -958,6 +970,7 @@ def my_tool_plain(*, a: int = 1, b: int) -> int:
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
},
{
'description': None,
Expand All @@ -972,6 +985,7 @@ def my_tool_plain(*, a: int = 1, b: int) -> int:
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
},
]
)
Expand Down Expand Up @@ -1059,6 +1073,7 @@ def my_tool(x: Annotated[str | None, WithJsonSchema({'type': 'string'})] = None,
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
},
{
'description': None,
Expand All @@ -1071,6 +1086,7 @@ def my_tool(x: Annotated[str | None, WithJsonSchema({'type': 'string'})] = None,
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
},
]
)
Expand Down Expand Up @@ -1107,6 +1123,7 @@ def get_score(data: Data) -> int: ... # pragma: no branch
'strict': None,
'kind': 'function',
'sequential': False,
'metadata': None,
}
)

Expand Down