Skip to content

Commit 25fb3a6

Browse files
ChuckJonasDouweM
andauthored
Added MCP metadata and annotations to ToolDefinition.metadata (#2880)
Co-authored-by: Douwe Maan <[email protected]>
1 parent ff0fbe3 commit 25fb3a6

File tree

9 files changed

+121
-1
lines changed

9 files changed

+121
-1
lines changed

docs/mcp/client.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,10 @@ calculator_server = MCPServerSSE(
318318
agent = Agent('openai:gpt-4o', toolsets=[weather_server, calculator_server])
319319
```
320320

321+
## Tool metadata
322+
323+
MCP tools can include metadata that provides additional information about the tool's characteristics, which can be useful when [filtering tools][pydantic_ai.toolsets.FilteredToolset]. The `meta`, `annotations`, and `output_schema` fields can be found on the `metadata` dict on the [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] object that's passed to filter functions.
324+
321325
## Custom TLS / SSL configuration
322326

323327
In some environments you need to tweak how HTTPS connections are established –

pydantic_ai_slim/pydantic_ai/agent/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,7 @@ def tool(
10061006
schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
10071007
strict: bool | None = None,
10081008
requires_approval: bool = False,
1009+
metadata: dict[str, Any] | None = None,
10091010
) -> Callable[[ToolFuncContext[AgentDepsT, ToolParams]], ToolFuncContext[AgentDepsT, ToolParams]]: ...
10101011

10111012
def tool(
@@ -1021,6 +1022,7 @@ def tool(
10211022
schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
10221023
strict: bool | None = None,
10231024
requires_approval: bool = False,
1025+
metadata: dict[str, Any] | None = None,
10241026
) -> Any:
10251027
"""Decorator to register a tool function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its first argument.
10261028
@@ -1067,6 +1069,7 @@ async def spam(ctx: RunContext[str], y: float) -> float:
10671069
See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
10681070
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
10691071
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
1072+
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
10701073
"""
10711074

10721075
def tool_decorator(
@@ -1083,7 +1086,9 @@ def tool_decorator(
10831086
require_parameter_descriptions,
10841087
schema_generator,
10851088
strict,
1089+
False,
10861090
requires_approval,
1091+
metadata,
10871092
)
10881093
return func_
10891094

@@ -1105,6 +1110,7 @@ def tool_plain(
11051110
schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
11061111
strict: bool | None = None,
11071112
requires_approval: bool = False,
1113+
metadata: dict[str, Any] | None = None,
11081114
) -> Callable[[ToolFuncPlain[ToolParams]], ToolFuncPlain[ToolParams]]: ...
11091115

11101116
def tool_plain(
@@ -1121,6 +1127,7 @@ def tool_plain(
11211127
strict: bool | None = None,
11221128
sequential: bool = False,
11231129
requires_approval: bool = False,
1130+
metadata: dict[str, Any] | None = None,
11241131
) -> Any:
11251132
"""Decorator to register a tool function which DOES NOT take `RunContext` as an argument.
11261133
@@ -1168,6 +1175,7 @@ async def spam(ctx: RunContext[str]) -> float:
11681175
sequential: Whether the function requires a sequential/serial execution environment. Defaults to False.
11691176
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
11701177
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
1178+
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
11711179
"""
11721180

11731181
def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams]:
@@ -1184,6 +1192,7 @@ def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams
11841192
strict,
11851193
sequential,
11861194
requires_approval,
1195+
metadata,
11871196
)
11881197
return func_
11891198

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,11 @@ async def get_tools(self, ctx: RunContext[Any]) -> dict[str, ToolsetTool[Any]]:
256256
name=name,
257257
description=mcp_tool.description,
258258
parameters_json_schema=mcp_tool.inputSchema,
259+
metadata={
260+
'meta': mcp_tool.meta,
261+
'annotations': mcp_tool.annotations.model_dump() if mcp_tool.annotations else None,
262+
'output_schema': mcp_tool.outputSchema or None,
263+
},
259264
),
260265
)
261266
for mcp_tool in await self.list_tools()

pydantic_ai_slim/pydantic_ai/tools.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ class Tool(Generic[AgentDepsT]):
255255
strict: bool | None
256256
sequential: bool
257257
requires_approval: bool
258+
metadata: dict[str, Any] | None
258259
function_schema: _function_schema.FunctionSchema
259260
"""
260261
The base JSON schema for the tool's parameters.
@@ -277,6 +278,7 @@ def __init__(
277278
strict: bool | None = None,
278279
sequential: bool = False,
279280
requires_approval: bool = False,
281+
metadata: dict[str, Any] | None = None,
280282
function_schema: _function_schema.FunctionSchema | None = None,
281283
):
282284
"""Create a new tool instance.
@@ -332,6 +334,7 @@ async def prep_my_tool(
332334
sequential: Whether the function requires a sequential/serial execution environment. Defaults to False.
333335
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
334336
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
337+
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
335338
function_schema: The function schema to use for the tool. If not provided, it will be generated.
336339
"""
337340
self.function = function
@@ -352,6 +355,7 @@ async def prep_my_tool(
352355
self.strict = strict
353356
self.sequential = sequential
354357
self.requires_approval = requires_approval
358+
self.metadata = metadata
355359

356360
@classmethod
357361
def from_schema(
@@ -406,6 +410,7 @@ def tool_def(self):
406410
parameters_json_schema=self.function_schema.json_schema,
407411
strict=self.strict,
408412
sequential=self.sequential,
413+
metadata=self.metadata,
409414
)
410415

411416
async def prepare_tool_def(self, ctx: RunContext[AgentDepsT]) -> ToolDefinition | None:
@@ -488,6 +493,12 @@ class ToolDefinition:
488493
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
489494
"""
490495

496+
metadata: dict[str, Any] | None = None
497+
"""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.
498+
499+
For MCP tools, this contains the `meta`, `annotations`, and `output_schema` fields from the tool definition.
500+
"""
501+
491502
@property
492503
def defer(self) -> bool:
493504
"""Whether calls to this tool will be deferred.

pydantic_ai_slim/pydantic_ai/toolsets/function.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def tool(
9999
strict: bool | None = None,
100100
sequential: bool = False,
101101
requires_approval: bool = False,
102+
metadata: dict[str, Any] | None = None,
102103
) -> Callable[[ToolFuncEither[AgentDepsT, ToolParams]], ToolFuncEither[AgentDepsT, ToolParams]]: ...
103104

104105
def tool(
@@ -115,6 +116,7 @@ def tool(
115116
strict: bool | None = None,
116117
sequential: bool = False,
117118
requires_approval: bool = False,
119+
metadata: dict[str, Any] | None = None,
118120
) -> Any:
119121
"""Decorator to register a tool function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its first argument.
120122
@@ -166,6 +168,7 @@ async def spam(ctx: RunContext[str], y: float) -> float:
166168
sequential: Whether the function requires a sequential/serial execution environment. Defaults to False.
167169
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
168170
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
171+
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
169172
"""
170173

171174
def tool_decorator(
@@ -184,6 +187,7 @@ def tool_decorator(
184187
strict,
185188
sequential,
186189
requires_approval,
190+
metadata,
187191
)
188192
return func_
189193

@@ -202,6 +206,7 @@ def add_function(
202206
strict: bool | None = None,
203207
sequential: bool = False,
204208
requires_approval: bool = False,
209+
metadata: dict[str, Any] | None = None,
205210
) -> None:
206211
"""Add a function as a tool to the toolset.
207212
@@ -230,6 +235,7 @@ def add_function(
230235
sequential: Whether the function requires a sequential/serial execution environment. Defaults to False.
231236
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
232237
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
238+
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
233239
"""
234240
if docstring_format is None:
235241
docstring_format = self.docstring_format
@@ -250,6 +256,7 @@ def add_function(
250256
strict=strict,
251257
sequential=sequential,
252258
requires_approval=requires_approval,
259+
metadata=metadata,
253260
)
254261
self.add_tool(tool)
255262

tests/mcp_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
1111
SamplingMessage,
1212
TextContent,
1313
TextResourceContents,
14+
ToolAnnotations,
1415
)
1516
from pydantic import AnyUrl, BaseModel
1617

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

2021

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

tests/test_logfire.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ async def my_ret(x: int) -> str:
387387
'strict': None,
388388
'sequential': False,
389389
'kind': 'function',
390+
'metadata': None,
390391
}
391392
],
392393
'builtin_tools': [],
@@ -780,6 +781,7 @@ class MyOutput:
780781
'strict': None,
781782
'sequential': False,
782783
'kind': 'output',
784+
'metadata': None,
783785
}
784786
],
785787
'allow_text_output': False,

tests/test_mcp.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,6 +1252,22 @@ async def test_tool_returning_multiple_items(allow_model_requests: None, agent:
12521252
)
12531253

12541254

1255+
async def test_tool_metadata_extraction():
1256+
"""Test that MCP tool metadata is properly extracted into ToolDefinition."""
1257+
1258+
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
1259+
async with server:
1260+
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
1261+
tools = [tool.tool_def for tool in (await server.get_tools(ctx)).values()]
1262+
# find `celsius_to_fahrenheit`
1263+
celsius_to_fahrenheit = next(tool for tool in tools if tool.name == 'celsius_to_fahrenheit')
1264+
assert celsius_to_fahrenheit.metadata is not None
1265+
assert celsius_to_fahrenheit.metadata.get('annotations') is not None
1266+
assert celsius_to_fahrenheit.metadata.get('annotations', {}).get('title', None) == 'Celsius to Fahrenheit'
1267+
assert celsius_to_fahrenheit.metadata.get('output_schema') is not None
1268+
assert celsius_to_fahrenheit.metadata.get('output_schema', {}).get('type', None) == 'object'
1269+
1270+
12551271
async def test_client_sampling(run_context: RunContext[int]):
12561272
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
12571273
server.sampling_model = TestModel(custom_output_text='sampling model response')

0 commit comments

Comments
 (0)