Skip to content

Commit be9c95f

Browse files
committed
Added MCP metadata to ToolDefinition
1 parent fa3dd3b commit be9c95f

File tree

4 files changed

+294
-0
lines changed

4 files changed

+294
-0
lines changed

docs/mcp/client.md

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

234+
## Tool Metadata
235+
236+
MCP tools often include metadata that provides additional information about the tool's characteristics, which can use useful in filtering and middleware. This metadata is available in the [`ToolDefinition.metadata`][pydantic_ai.tools.ToolDefinition.metadata] field.
237+
234238
## Custom TLS / SSL configuration
235239

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

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ async def get_tools(self, ctx: RunContext[Any]) -> dict[str, ToolsetTool[Any]]:
254254
name=name,
255255
description=mcp_tool.description,
256256
parameters_json_schema=mcp_tool.inputSchema,
257+
metadata=mcp_tool.meta,
257258
),
258259
)
259260
for mcp_tool in await self.list_tools()

pydantic_ai_slim/pydantic_ai/tools.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,12 @@ class ToolDefinition:
488488
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
489489
"""
490490

491+
metadata: dict[str, Any] | None = None
492+
"""Tool metadata, primarily used for filtering and tool behavior customization.
493+
494+
For MCP tools, this contains the `_meta` field from the tool definition.
495+
"""
496+
491497
@property
492498
def defer(self) -> bool:
493499
"""Whether calls to this tool will be deferred.

tests/test_mcp_metadata.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""Tests for MCP tool metadata support and filtering."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import AsyncIterator
6+
from contextlib import asynccontextmanager
7+
from typing import Any
8+
9+
import pytest
10+
11+
from pydantic_ai.models.test import TestModel
12+
from pydantic_ai.tools import RunContext, ToolDefinition
13+
from pydantic_ai.usage import RunUsage
14+
15+
from .conftest import try_import
16+
17+
with try_import() as imports_successful:
18+
from mcp import types as mcp_types
19+
20+
from pydantic_ai.mcp import MCPServer
21+
22+
pytestmark = [
23+
pytest.mark.skipif(not imports_successful(), reason='mcp not installed'),
24+
pytest.mark.anyio,
25+
]
26+
27+
28+
class MockMCPServer(MCPServer):
29+
"""Mock MCP server for testing metadata functionality."""
30+
31+
def __init__(self, mock_tools: list[mcp_types.Tool], **kwargs: Any):
32+
super().__init__(**kwargs)
33+
self._mock_tools = mock_tools
34+
35+
@asynccontextmanager
36+
async def client_streams(self) -> AsyncIterator[tuple[Any, Any]]:
37+
"""Not used in these tests."""
38+
raise NotImplementedError('Mock server does not implement streams')
39+
# This is unreachable but needed for type checking
40+
yield None, None # pragma: no cover
41+
42+
async def list_tools(self) -> list[mcp_types.Tool]:
43+
"""Return mock tools with metadata."""
44+
return self._mock_tools
45+
46+
async def direct_call_tool(self, name: str, args: dict[str, Any], metadata: dict[str, Any] | None = None):
47+
"""Mock tool call - not used in metadata tests."""
48+
return f'Called {name} with {args}'
49+
50+
51+
async def test_tool_metadata_extraction():
52+
"""Test that MCP tool metadata is properly extracted into ToolDefinition."""
53+
# Create mock tools with different metadata
54+
mock_tools = [
55+
mcp_types.Tool(
56+
name='simple_tool',
57+
description='A simple tool without metadata',
58+
inputSchema={'type': 'object', 'properties': {}},
59+
_meta=None,
60+
),
61+
mcp_types.Tool(
62+
name='complex_tool',
63+
description='A complex tool with metadata',
64+
inputSchema={'type': 'object', 'properties': {'query': {'type': 'string'}}},
65+
_meta={'complexity': 'high', 'category': 'analysis'},
66+
),
67+
mcp_types.Tool(
68+
name='auth_tool',
69+
description='Tool with authentication metadata',
70+
inputSchema={'type': 'object', 'properties': {}},
71+
_meta={'complexity': 'low', '_fastmcp': {'tags': ['unauthenticated', 'public']}},
72+
),
73+
]
74+
75+
server = MockMCPServer(mock_tools)
76+
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
77+
78+
tools = await server.get_tools(ctx)
79+
80+
# Check that we have all tools
81+
assert len(tools) == 3
82+
assert 'simple_tool' in tools
83+
assert 'complex_tool' in tools
84+
assert 'auth_tool' in tools
85+
86+
# Check metadata extraction
87+
simple_tool = tools['simple_tool'].tool_def
88+
assert simple_tool.name == 'simple_tool'
89+
assert simple_tool.metadata is None
90+
91+
complex_tool = tools['complex_tool'].tool_def
92+
assert complex_tool.name == 'complex_tool'
93+
assert complex_tool.metadata == {'complexity': 'high', 'category': 'analysis'}
94+
95+
auth_tool = tools['auth_tool'].tool_def
96+
assert auth_tool.name == 'auth_tool'
97+
assert auth_tool.metadata == {'complexity': 'low', '_fastmcp': {'tags': ['unauthenticated', 'public']}}
98+
99+
100+
async def test_tool_filtering_by_complexity():
101+
"""Test filtering tools by complexity metadata."""
102+
mock_tools = [
103+
mcp_types.Tool(
104+
name='simple_tool',
105+
description='Simple tool',
106+
inputSchema={'type': 'object'},
107+
_meta={'complexity': 'low'},
108+
),
109+
mcp_types.Tool(
110+
name='complex_tool',
111+
description='Complex tool',
112+
inputSchema={'type': 'object'},
113+
_meta={'complexity': 'high'},
114+
),
115+
mcp_types.Tool(
116+
name='no_meta_tool',
117+
description='Tool without metadata',
118+
inputSchema={'type': 'object'},
119+
_meta=None,
120+
),
121+
]
122+
123+
server = MockMCPServer(mock_tools)
124+
125+
# Filter to exclude high complexity tools
126+
def complexity_filter(ctx: RunContext, tool_def: ToolDefinition) -> bool:
127+
if tool_def.metadata is None:
128+
return True # Include tools without metadata
129+
return tool_def.metadata.get('complexity') != 'high'
130+
131+
filtered_server = server.filtered(complexity_filter)
132+
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
133+
134+
filtered_tools = await filtered_server.get_tools(ctx)
135+
136+
# Should only have simple_tool and no_meta_tool
137+
assert len(filtered_tools) == 2
138+
assert 'simple_tool' in filtered_tools
139+
assert 'no_meta_tool' in filtered_tools
140+
assert 'complex_tool' not in filtered_tools
141+
142+
143+
async def test_tool_filtering_by_tags():
144+
"""Test filtering tools by FastMCP tags."""
145+
mock_tools = [
146+
mcp_types.Tool(
147+
name='public_tool',
148+
description='Public tool',
149+
inputSchema={'type': 'object'},
150+
_meta={'_fastmcp': {'tags': ['unauthenticated', 'public']}},
151+
),
152+
mcp_types.Tool(
153+
name='private_tool',
154+
description='Private tool',
155+
inputSchema={'type': 'object'},
156+
_meta={'_fastmcp': {'tags': ['authenticated', 'private']}},
157+
),
158+
mcp_types.Tool(
159+
name='no_tags_tool',
160+
description='Tool without tags',
161+
inputSchema={'type': 'object'},
162+
_meta={'some_other': 'metadata'},
163+
),
164+
]
165+
166+
server = MockMCPServer(mock_tools)
167+
168+
# Filter to only include unauthenticated tools
169+
def unauthenticated_filter(ctx: RunContext, tool_def: ToolDefinition) -> bool:
170+
if tool_def.metadata is None:
171+
return False
172+
fastmcp_meta = tool_def.metadata.get('_fastmcp', {})
173+
tags = fastmcp_meta.get('tags', [])
174+
return 'unauthenticated' in tags
175+
176+
filtered_server = server.filtered(unauthenticated_filter)
177+
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
178+
179+
filtered_tools = await filtered_server.get_tools(ctx)
180+
181+
# Should only have public_tool
182+
assert len(filtered_tools) == 1
183+
assert 'public_tool' in filtered_tools
184+
assert 'private_tool' not in filtered_tools
185+
assert 'no_tags_tool' not in filtered_tools
186+
187+
188+
async def test_tool_prefix_with_metadata():
189+
"""Test that tool prefixing works correctly with metadata."""
190+
mock_tools = [
191+
mcp_types.Tool(
192+
name='original_tool',
193+
description='Tool with metadata',
194+
inputSchema={'type': 'object'},
195+
_meta={'category': 'test'},
196+
),
197+
]
198+
199+
server = MockMCPServer(mock_tools, tool_prefix='prefix')
200+
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
201+
202+
tools = await server.get_tools(ctx)
203+
204+
# Tool should be prefixed but metadata preserved
205+
assert len(tools) == 1
206+
assert 'prefix_original_tool' in tools
207+
208+
tool_def = tools['prefix_original_tool'].tool_def
209+
assert tool_def.name == 'prefix_original_tool'
210+
assert tool_def.metadata == {'category': 'test'}
211+
212+
213+
async def test_combined_filtering():
214+
"""Test combining multiple filter criteria."""
215+
mock_tools = [
216+
mcp_types.Tool(
217+
name='simple_public',
218+
description='Simple public tool',
219+
inputSchema={'type': 'object'},
220+
_meta={'complexity': 'low', '_fastmcp': {'tags': ['unauthenticated']}},
221+
),
222+
mcp_types.Tool(
223+
name='complex_public',
224+
description='Complex public tool',
225+
inputSchema={'type': 'object'},
226+
_meta={'complexity': 'high', '_fastmcp': {'tags': ['unauthenticated']}},
227+
),
228+
mcp_types.Tool(
229+
name='simple_private',
230+
description='Simple private tool',
231+
inputSchema={'type': 'object'},
232+
_meta={'complexity': 'low', '_fastmcp': {'tags': ['authenticated']}},
233+
),
234+
]
235+
236+
server = MockMCPServer(mock_tools)
237+
238+
# Filter for simple AND unauthenticated tools
239+
def combined_filter(ctx: RunContext, tool_def: ToolDefinition) -> bool:
240+
if tool_def.metadata is None:
241+
return False
242+
243+
# Check complexity
244+
if tool_def.metadata.get('complexity') != 'low':
245+
return False
246+
247+
# Check tags
248+
fastmcp_meta = tool_def.metadata.get('_fastmcp', {})
249+
tags = fastmcp_meta.get('tags', [])
250+
if 'unauthenticated' not in tags:
251+
return False
252+
253+
return True
254+
255+
filtered_server = server.filtered(combined_filter)
256+
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
257+
258+
filtered_tools = await filtered_server.get_tools(ctx)
259+
260+
# Should only have simple_public
261+
assert len(filtered_tools) == 1
262+
assert 'simple_public' in filtered_tools
263+
264+
265+
async def test_empty_metadata():
266+
"""Test handling of empty metadata."""
267+
mock_tools = [
268+
mcp_types.Tool(
269+
name='empty_meta_tool',
270+
description='Tool with empty metadata dict',
271+
inputSchema={'type': 'object'},
272+
_meta={},
273+
),
274+
]
275+
276+
server = MockMCPServer(mock_tools)
277+
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
278+
279+
tools = await server.get_tools(ctx)
280+
281+
assert len(tools) == 1
282+
tool_def = tools['empty_meta_tool'].tool_def
283+
assert tool_def.metadata == {}

0 commit comments

Comments
 (0)