From 5e9623387ba2e6e615938ae2ea41dc9313668ac6 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 24 Oct 2025 02:11:11 +0000 Subject: [PATCH 1/3] Fix support for multiple MCPServerTool builtin_tools --- .../pydantic_ai/agent/__init__.py | 14 +- pydantic_ai_slim/pydantic_ai/builtin_tools.py | 14 +- .../pydantic_ai/models/__init__.py | 14 +- .../pydantic_ai/models/anthropic.py | 34 +++-- pydantic_ai_slim/pydantic_ai/models/openai.py | 17 +-- tests/models/test_anthropic.py | 22 ++- tests/models/test_model_request_parameters.py | 21 +++ tests/models/test_openai_responses.py | 51 ++++--- tests/test_agent.py | 129 ++++-------------- 9 files changed, 142 insertions(+), 174 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 6e4a834bf6..4160b13175 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -542,6 +542,7 @@ async def main(): """ if infer_name and self.name is None: self._infer_name(inspect.currentframe()) + model_used = self._get_model(model) del model @@ -607,16 +608,7 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None: else: instrumentation_settings = None tracer = NoOpTracer() - if builtin_tools: - # Deduplicate builtin tools passed to the agent and the run based on type - builtin_tools = list( - { - **({type(tool): tool for tool in self._builtin_tools or []}), - **({type(tool): tool for tool in builtin_tools}), - }.values() - ) - else: - builtin_tools = list(self._builtin_tools) + graph_deps = _agent_graph.GraphAgentDeps[AgentDepsT, RunOutputDataT]( user_deps=deps, prompt=user_prompt, @@ -629,7 +621,7 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None: output_schema=output_schema, output_validators=output_validators, history_processors=self.history_processors, - builtin_tools=builtin_tools, + builtin_tools=[*self._builtin_tools, *(builtin_tools or [])], tool_manager=tool_manager, tracer=tracer, get_instructions=get_instructions, diff --git a/pydantic_ai_slim/pydantic_ai/builtin_tools.py b/pydantic_ai_slim/pydantic_ai/builtin_tools.py index 731594eb25..a30501af59 100644 --- a/pydantic_ai_slim/pydantic_ai/builtin_tools.py +++ b/pydantic_ai_slim/pydantic_ai/builtin_tools.py @@ -34,6 +34,14 @@ class AbstractBuiltinTool(ABC): kind: str = 'unknown_builtin_tool' """Built-in tool identifier, this should be available on all built-in tools as a discriminator.""" + @property + def unique_id(self) -> str: + """A unique identifier for the builtin tool. + + If multiple instances of the same builtin tool can be passed to the model, this should be used to distinguish them. + """ + return self.kind + def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) _BUILTIN_TOOL_TYPES[cls.kind] = cls @@ -275,7 +283,7 @@ class MCPServerTool(AbstractBuiltinTool): """ id: str - """The ID of the MCP server.""" + """A unique identifier for the MCP server.""" url: str """The URL of the MCP server to use. @@ -321,6 +329,10 @@ class MCPServerTool(AbstractBuiltinTool): kind: str = 'mcp_server' + @property + def unique_id(self) -> str: + return ':'.join([self.kind, self.id]) + def _tool_discriminator(tool_data: dict[str, Any] | AbstractBuiltinTool) -> str: if isinstance(tool_data, dict): diff --git a/pydantic_ai_slim/pydantic_ai/models/__init__.py b/pydantic_ai_slim/pydantic_ai/models/__init__.py index c9c7f6bc40..2731f07ac9 100644 --- a/pydantic_ai_slim/pydantic_ai/models/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/models/__init__.py @@ -410,9 +410,17 @@ def prepare_request( they need to customize the preparation flow further, but most implementations should simply call ``self.prepare_request(...)`` at the start of their ``request`` (and related) methods. """ - merged_settings = merge_model_settings(self.settings, model_settings) - customized_parameters = self.customize_request_parameters(model_request_parameters) - return merged_settings, customized_parameters + model_settings = merge_model_settings(self.settings, model_settings) + + if builtin_tools := model_request_parameters.builtin_tools: + # Deduplicate builtin tools + model_request_parameters = replace( + model_request_parameters, + builtin_tools=list({tool.unique_id: tool for tool in builtin_tools}.values()), + ) + + model_request_parameters = self.customize_request_parameters(model_request_parameters) + return model_settings, model_request_parameters @property @abstractmethod diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index bbedf06a10..c3957170dd 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -324,11 +324,14 @@ async def _messages_create( def _process_response(self, response: BetaMessage) -> ModelResponse: """Process a non-streamed response, and prepare a message to return.""" items: list[ModelResponsePart] = [] + builtin_tool_calls: dict[str, BuiltinToolCallPart] = {} for item in response.content: if isinstance(item, BetaTextBlock): items.append(TextPart(content=item.text)) elif isinstance(item, BetaServerToolUseBlock): - items.append(_map_server_tool_use_block(item, self.system)) + call_part = _map_server_tool_use_block(item, self.system) + builtin_tool_calls[call_part.tool_call_id] = call_part + items.append(call_part) elif isinstance(item, BetaWebSearchToolResultBlock): items.append(_map_web_search_tool_result_block(item, self.system)) elif isinstance(item, BetaCodeExecutionToolResultBlock): @@ -340,9 +343,12 @@ def _process_response(self, response: BetaMessage) -> ModelResponse: elif isinstance(item, BetaThinkingBlock): items.append(ThinkingPart(content=item.thinking, signature=item.signature, provider_name=self.system)) elif isinstance(item, BetaMCPToolUseBlock): - items.append(_map_mcp_server_use_block(item, self.system)) + call_part = _map_mcp_server_use_block(item, self.system) + builtin_tool_calls[call_part.tool_call_id] = call_part + items.append(call_part) elif isinstance(item, BetaMCPToolResultBlock): - items.append(_map_mcp_server_result_block(item, self.system)) + call_part = builtin_tool_calls.get(item.tool_use_id) + items.append(_map_mcp_server_result_block(item, call_part, self.system)) else: assert isinstance(item, BetaToolUseBlock), f'unexpected item type {type(item)}' items.append( @@ -545,9 +551,9 @@ async def _map_message(self, messages: list[ModelMessage]) -> tuple[str, list[Be ) assistant_content_params.append(server_tool_use_block_param) elif ( - response_part.tool_name == MCPServerTool.kind + response_part.tool_name.startswith(MCPServerTool.kind) + and (server_id := response_part.tool_name.split(':', 1)[1]) and (args := response_part.args_as_dict()) - and (server_id := args.get('server_id')) and (tool_name := args.get('tool_name')) and (tool_args := args.get('tool_args')) ): # pragma: no branch @@ -714,6 +720,7 @@ class AnthropicStreamedResponse(StreamedResponse): async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: # noqa: C901 current_block: BetaContentBlock | None = None + builtin_tool_calls: dict[str, BuiltinToolCallPart] = {} async for event in self._response: if isinstance(event, BetaRawMessageStartEvent): self._usage = _map_usage(event, self._provider_name, self._provider_url, self._model_name) @@ -751,9 +758,11 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: if maybe_event is not None: # pragma: no branch yield maybe_event elif isinstance(current_block, BetaServerToolUseBlock): + call_part = _map_server_tool_use_block(current_block, self.provider_name) + builtin_tool_calls[call_part.tool_call_id] = call_part yield self._parts_manager.handle_part( vendor_part_id=event.index, - part=_map_server_tool_use_block(current_block, self.provider_name), + part=call_part, ) elif isinstance(current_block, BetaWebSearchToolResultBlock): yield self._parts_manager.handle_part( @@ -767,6 +776,7 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: ) elif isinstance(current_block, BetaMCPToolUseBlock): call_part = _map_mcp_server_use_block(current_block, self.provider_name) + builtin_tool_calls[call_part.tool_call_id] = call_part args_json = call_part.args_as_json_str() # Drop the final `{}}` so that we can add tool args deltas @@ -785,9 +795,10 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: if maybe_event is not None: # pragma: no branch yield maybe_event elif isinstance(current_block, BetaMCPToolResultBlock): + call_part = builtin_tool_calls.get(current_block.tool_use_id) yield self._parts_manager.handle_part( vendor_part_id=event.index, - part=_map_mcp_server_result_block(current_block, self.provider_name), + part=_map_mcp_server_result_block(current_block, call_part, self.provider_name), ) elif isinstance(event, BetaRawContentBlockDeltaEvent): @@ -908,10 +919,9 @@ def _map_code_execution_tool_result_block( def _map_mcp_server_use_block(item: BetaMCPToolUseBlock, provider_name: str) -> BuiltinToolCallPart: return BuiltinToolCallPart( provider_name=provider_name, - tool_name=MCPServerTool.kind, + tool_name=':'.join([MCPServerTool.kind, item.server_name]), args={ 'action': 'call_tool', - 'server_id': item.server_name, 'tool_name': item.name, 'tool_args': cast(dict[str, Any], item.input), }, @@ -919,10 +929,12 @@ def _map_mcp_server_use_block(item: BetaMCPToolUseBlock, provider_name: str) -> ) -def _map_mcp_server_result_block(item: BetaMCPToolResultBlock, provider_name: str) -> BuiltinToolReturnPart: +def _map_mcp_server_result_block( + item: BetaMCPToolResultBlock, call_part: BuiltinToolCallPart | None, provider_name: str +) -> BuiltinToolReturnPart: return BuiltinToolReturnPart( provider_name=provider_name, - tool_name=MCPServerTool.kind, + tool_name=call_part.tool_name if call_part else MCPServerTool.kind, content=item.model_dump(mode='json', include={'content', 'is_error'}), tool_call_id=item.tool_use_id, ) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 09d8fb1420..fd18cff130 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -1474,11 +1474,11 @@ async def _map_messages( # noqa: C901 ) openai_messages.append(image_generation_item) elif ( # pragma: no branch - item.tool_name == MCPServerTool.kind + item.tool_name.startswith(MCPServerTool.kind) and item.tool_call_id + and (server_id := item.tool_name.split(':', 1)[1]) and (args := item.args_as_dict()) and (action := args.get('action')) - and (server_id := args.get('server_id')) ): if action == 'list_tools': mcp_list_tools_item = responses.response_input_item_param.McpListTools( @@ -2257,15 +2257,16 @@ def _map_image_generation_tool_call( def _map_mcp_list_tools( item: responses.response_output_item.McpListTools, provider_name: str ) -> tuple[BuiltinToolCallPart, BuiltinToolReturnPart]: + tool_name = ':'.join([MCPServerTool.kind, item.server_label]) return ( BuiltinToolCallPart( - tool_name=MCPServerTool.kind, + tool_name=tool_name, tool_call_id=item.id, provider_name=provider_name, - args={'action': 'list_tools', 'server_id': item.server_label}, + args={'action': 'list_tools'}, ), BuiltinToolReturnPart( - tool_name=MCPServerTool.kind, + tool_name=tool_name, tool_call_id=item.id, content=item.model_dump(mode='json', include={'tools', 'error'}), provider_name=provider_name, @@ -2276,20 +2277,20 @@ def _map_mcp_list_tools( def _map_mcp_call( item: responses.response_output_item.McpCall, provider_name: str ) -> tuple[BuiltinToolCallPart, BuiltinToolReturnPart]: + tool_name = ':'.join([MCPServerTool.kind, item.server_label]) return ( BuiltinToolCallPart( - tool_name=MCPServerTool.kind, + tool_name=tool_name, tool_call_id=item.id, args={ 'action': 'call_tool', - 'server_id': item.server_label, 'tool_name': item.name, 'tool_args': json.loads(item.arguments) if item.arguments else {}, }, provider_name=provider_name, ), BuiltinToolReturnPart( - tool_name=MCPServerTool.kind, + tool_name=tool_name, tool_call_id=item.id, content={ 'output': item.output, diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index aa78ff0904..d0690366b8 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -3098,10 +3098,9 @@ async def test_anthropic_mcp_servers(allow_model_requests: None, anthropic_api_k provider_name='anthropic', ), BuiltinToolCallPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', args={ 'action': 'call_tool', - 'server_id': 'deepwiki', 'tool_name': 'ask_question', 'tool_args': { 'repoName': 'pydantic/pydantic-ai', @@ -3112,7 +3111,7 @@ async def test_anthropic_mcp_servers(allow_model_requests: None, anthropic_api_k provider_name='anthropic', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'content': [ { @@ -3181,10 +3180,9 @@ async def test_anthropic_mcp_servers(allow_model_requests: None, anthropic_api_k provider_name='anthropic', ), BuiltinToolCallPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', args={ 'action': 'call_tool', - 'server_id': 'deepwiki', 'tool_name': 'ask_question', 'tool_args': { 'repoName': 'pydantic/pydantic', @@ -3195,7 +3193,7 @@ async def test_anthropic_mcp_servers(allow_model_requests: None, anthropic_api_k provider_name='anthropic', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'content': [ { @@ -3345,13 +3343,13 @@ async def test_anthropic_mcp_servers_stream(allow_model_requests: None, anthropi provider_name='anthropic', ), BuiltinToolCallPart( - tool_name='mcp_server', - args='{"action":"call_tool","server_id":"deepwiki","tool_name":"ask_question","tool_args":{"repoName": "pydantic/pydantic-ai", "question": "What is this repository about? What are its main features and purpose?"}}', + tool_name='mcp_server:deepwiki', + args='{"action":"call_tool","tool_name":"ask_question","tool_args":{"repoName": "pydantic/pydantic-ai", "question": "What is this repository about? What are its main features and purpose?"}}', tool_call_id='mcptoolu_01FZmJ5UspaX5BB9uU339UT1', provider_name='anthropic', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'content': [ { @@ -3407,7 +3405,7 @@ async def test_anthropic_mcp_servers_stream(allow_model_requests: None, anthropi PartStartEvent( index=1, part=BuiltinToolCallPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', tool_call_id='mcptoolu_01FZmJ5UspaX5BB9uU339UT1', provider_name='anthropic', ), @@ -3415,7 +3413,7 @@ async def test_anthropic_mcp_servers_stream(allow_model_requests: None, anthropi PartDeltaEvent( index=1, delta=ToolCallPartDelta( - args_delta='{"action":"call_tool","server_id":"deepwiki","tool_name":"ask_question","tool_args":', + args_delta='{"action":"call_tool","tool_name":"ask_question","tool_args":', tool_call_id='mcptoolu_01FZmJ5UspaX5BB9uU339UT1', ), ), @@ -3489,7 +3487,7 @@ async def test_anthropic_mcp_servers_stream(allow_model_requests: None, anthropi PartStartEvent( index=2, part=BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'content': [ { diff --git a/tests/models/test_model_request_parameters.py b/tests/models/test_model_request_parameters.py index 8c2aa514b2..c7e87d44c2 100644 --- a/tests/models/test_model_request_parameters.py +++ b/tests/models/test_model_request_parameters.py @@ -4,6 +4,7 @@ from pydantic_ai.builtin_tools import ( CodeExecutionTool, ImageGenerationTool, + MCPServerTool, MemoryTool, UrlContextTool, WebSearchTool, @@ -45,6 +46,8 @@ def test_model_request_parameters_are_serializable(): UrlContextTool(), ImageGenerationTool(size='1024x1024'), MemoryTool(), + MCPServerTool(id='deepwiki', url='https://mcp.deepwiki.com/mcp'), + MCPServerTool(id='github', url='https://api.githubcopilot.com/mcp'), ], output_mode='text', allow_text_output=True, @@ -89,6 +92,24 @@ def test_model_request_parameters_are_serializable(): 'size': '1024x1024', }, {'kind': 'memory'}, + { + 'kind': 'mcp_server', + 'id': 'deepwiki', + 'url': 'https://mcp.deepwiki.com/mcp', + 'authorization_token': None, + 'description': None, + 'allowed_tools': None, + 'headers': None, + }, + { + 'kind': 'mcp_server', + 'id': 'github', + 'url': 'https://api.githubcopilot.com/mcp', + 'authorization_token': None, + 'description': None, + 'allowed_tools': None, + 'headers': None, + }, ], 'output_mode': 'text', 'output_object': None, diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 4cbaffc87b..743a586919 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -6356,13 +6356,13 @@ async def test_openai_responses_model_mcp_server_tool(allow_model_requests: None ModelResponse( parts=[ BuiltinToolCallPart( - tool_name='mcp_server', - args={'action': 'list_tools', 'server_id': 'deepwiki'}, + tool_name='mcp_server:deepwiki', + args={'action': 'list_tools'}, tool_call_id='mcpl_0083938b3a28070e0068fabd81d51081a09d4b183ced693273', provider_name='openai', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'tools': [ { @@ -6400,10 +6400,9 @@ async def test_openai_responses_model_mcp_server_tool(allow_model_requests: None provider_name='openai', ), BuiltinToolCallPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', args={ 'action': 'call_tool', - 'server_id': 'deepwiki', 'tool_name': 'ask_question', 'tool_args': { 'repoName': 'pydantic/pydantic-ai', @@ -6414,7 +6413,7 @@ async def test_openai_responses_model_mcp_server_tool(allow_model_requests: None provider_name='openai', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'output': """\ Pydantic AI is a Python agent framework designed to build production-grade applications using Generative AI, emphasizing an ergonomic developer experience and type-safety . It provides type-safe agents, a model-agnostic design supporting over 15 LLM providers, structured outputs with Pydantic validation, comprehensive observability, and production-ready tooling . The project is structured as a UV workspace monorepo, including core framework components, an evaluation system, a graph execution engine, examples, and a CLI tool . @@ -6574,13 +6573,13 @@ async def test_openai_responses_model_mcp_server_tool_stream(allow_model_request ModelResponse( parts=[ BuiltinToolCallPart( - tool_name='mcp_server', - args={'action': 'list_tools', 'server_id': 'deepwiki'}, + tool_name='mcp_server:deepwiki', + args={'action': 'list_tools'}, tool_call_id='mcpl_00b9cc7a23d047270068faa0e29804819fb060cec0408ffbcd', provider_name='openai', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'tools': [ { @@ -6635,13 +6634,13 @@ async def test_openai_responses_model_mcp_server_tool_stream(allow_model_request provider_name='openai', ), BuiltinToolCallPart( - tool_name='mcp_server', - args='{"action":"call_tool","server_id":"deepwiki","tool_name":"ask_question","tool_args":{"repoName":"pydantic/pydantic-ai","question":"What is the pydantic/pydantic-ai repository about?"}}', + tool_name='mcp_server:deepwiki', + args='{"action":"call_tool","tool_name":"ask_question","tool_args":{"repoName":"pydantic/pydantic-ai","question":"What is the pydantic/pydantic-ai repository about?"}}', tool_call_id='mcp_00b9cc7a23d047270068faa0e67fb0819fa9e21302c398e9ac', provider_name='openai', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'error': None, 'output': """\ @@ -6751,8 +6750,8 @@ async def test_openai_responses_model_mcp_server_tool_stream(allow_model_request PartStartEvent( index=0, part=BuiltinToolCallPart( - tool_name='mcp_server', - args={'action': 'list_tools', 'server_id': 'deepwiki'}, + tool_name='mcp_server:deepwiki', + args={'action': 'list_tools'}, tool_call_id='mcpl_00b9cc7a23d047270068faa0e29804819fb060cec0408ffbcd', provider_name='openai', ), @@ -6760,7 +6759,7 @@ async def test_openai_responses_model_mcp_server_tool_stream(allow_model_request PartStartEvent( index=1, part=BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'tools': [ { @@ -6812,7 +6811,7 @@ async def test_openai_responses_model_mcp_server_tool_stream(allow_model_request PartStartEvent( index=3, part=BuiltinToolCallPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', tool_call_id='mcp_00b9cc7a23d047270068faa0e67fb0819fa9e21302c398e9ac', provider_name='openai', ), @@ -6820,7 +6819,7 @@ async def test_openai_responses_model_mcp_server_tool_stream(allow_model_request PartDeltaEvent( index=3, delta=ToolCallPartDelta( - args_delta='{"action":"call_tool","server_id":"deepwiki","tool_name":"ask_question","tool_args":', + args_delta='{"action":"call_tool","tool_name":"ask_question","tool_args":', tool_call_id='mcp_00b9cc7a23d047270068faa0e67fb0819fa9e21302c398e9ac', ), ), @@ -6840,7 +6839,7 @@ async def test_openai_responses_model_mcp_server_tool_stream(allow_model_request PartStartEvent( index=4, part=BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:deepwiki', content={ 'error': None, 'output': """\ @@ -6959,13 +6958,13 @@ async def test_openai_responses_model_mcp_server_tool_with_connector(allow_model ModelResponse( parts=[ BuiltinToolCallPart( - tool_name='mcp_server', - args={'action': 'list_tools', 'server_id': 'google_calendar'}, + tool_name='mcp_server:google_calendar', + args={'action': 'list_tools'}, tool_call_id='mcpl_0558010cf1416a490068faa0f9679481a082dc4ac08889f104', provider_name='openai', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:google_calendar', content={ 'tools': [ { @@ -7028,10 +7027,9 @@ async def test_openai_responses_model_mcp_server_tool_with_connector(allow_model provider_name='openai', ), BuiltinToolCallPart( - tool_name='mcp_server', + tool_name='mcp_server:google_calendar', args={ 'action': 'call_tool', - 'server_id': 'google_calendar', 'tool_name': 'search_events', 'tool_args': { 'time_min': '2025-10-23T00:00:00', @@ -7047,7 +7045,7 @@ async def test_openai_responses_model_mcp_server_tool_with_connector(allow_model provider_name='openai', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:google_calendar', content={ 'output': None, 'error': { @@ -7067,10 +7065,9 @@ async def test_openai_responses_model_mcp_server_tool_with_connector(allow_model provider_name='openai', ), BuiltinToolCallPart( - tool_name='mcp_server', + tool_name='mcp_server:google_calendar', args={ 'action': 'call_tool', - 'server_id': 'google_calendar', 'tool_name': 'search_events', 'tool_args': { 'time_min': '2025-10-23T00:00:00Z', @@ -7086,7 +7083,7 @@ async def test_openai_responses_model_mcp_server_tool_with_connector(allow_model provider_name='openai', ), BuiltinToolReturnPart( - tool_name='mcp_server', + tool_name='mcp_server:google_calendar', content={ 'output': None, 'error': { diff --git a/tests/test_agent.py b/tests/test_agent.py index baadc7dbba..904b7deda4 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -58,12 +58,11 @@ ToolOutputSchema, ) from pydantic_ai.agent import AgentRunResult, WrapperAgent -from pydantic_ai.builtin_tools import WebSearchTool +from pydantic_ai.builtin_tools import CodeExecutionTool, MCPServerTool, WebSearchTool from pydantic_ai.models.function import AgentInfo, FunctionModel from pydantic_ai.models.test import TestModel from pydantic_ai.output import StructuredDict, ToolOutput from pydantic_ai.result import RunUsage -from pydantic_ai.run import AgentRunResultEvent from pydantic_ai.settings import ModelSettings from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults, ToolDefinition, ToolDenied from pydantic_ai.usage import RequestUsage @@ -5617,110 +5616,38 @@ def roll_dice() -> int: assert not any(isinstance(p, ToolReturnPart) and p.tool_name == 'final_result' for p in new_messages[0].parts) -def test_agent_builtin_tools_runtime_parameter(): - """Test that Agent.run_sync accepts builtin_tools parameter.""" - model = TestModel() - agent = Agent(model=model, builtin_tools=[]) - - # Should work with empty builtin_tools - result = agent.run_sync('Hello', builtin_tools=[]) - assert result.output == 'success (no tool calls)' - - assert model.last_model_request_parameters is not None - assert model.last_model_request_parameters.builtin_tools == [] - - -async def test_agent_builtin_tools_runtime_parameter_async(): - """Test that Agent.run and Agent.run_stream accept builtin_tools parameter.""" - model = TestModel() - agent = Agent(model=model, builtin_tools=[]) - - # Test async run - result = await agent.run('Hello', builtin_tools=[]) - assert result.output == 'success (no tool calls)' - - assert model.last_model_request_parameters is not None - assert model.last_model_request_parameters.builtin_tools == [] - - # Test run_stream - async with agent.run_stream('Hello', builtin_tools=[]) as stream: - output = await stream.get_output() - assert output == 'success (no tool calls)' - - assert model.last_model_request_parameters is not None - assert model.last_model_request_parameters.builtin_tools == [] - - -def test_agent_builtin_tools_testmodel_rejection(): - """Test that TestModel rejects builtin tools as expected.""" - model = TestModel() - agent = Agent(model=model, builtin_tools=[]) - - # Should raise error when builtin_tools contains actual tools - web_search_tool = WebSearchTool() - with pytest.raises(Exception, match='TestModel does not support built-in tools'): - agent.run_sync('Hello', builtin_tools=[web_search_tool]) - - assert model.last_model_request_parameters is not None - assert len(model.last_model_request_parameters.builtin_tools) == 1 - assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool - - def test_agent_builtin_tools_runtime_vs_agent_level(): """Test that runtime builtin_tools parameter is merged with agent-level builtin_tools.""" model = TestModel() - web_search_tool = WebSearchTool() - - # Agent has builtin tools, and we provide same type at runtime - agent = Agent(model=model, builtin_tools=[web_search_tool]) - # Runtime tool of same type should override agent-level tool - different_web_search = WebSearchTool(search_context_size='high') - with pytest.raises(Exception, match='TestModel does not support built-in tools'): - agent.run_sync('Hello', builtin_tools=[different_web_search]) - - assert model.last_model_request_parameters is not None - assert len(model.last_model_request_parameters.builtin_tools) == 1 - runtime_tool = model.last_model_request_parameters.builtin_tools[0] - assert isinstance(runtime_tool, WebSearchTool) - assert runtime_tool.search_context_size == 'high' - - -def test_agent_builtin_tools_runtime_additional(): - """Test that runtime builtin_tools can add to agent-level builtin_tools when different types.""" - model = TestModel() - web_search_tool = WebSearchTool() - - agent = Agent(model=model, builtin_tools=[]) - - with pytest.raises(Exception, match='TestModel does not support built-in tools'): - agent.run_sync('Hello', builtin_tools=[web_search_tool]) - - assert model.last_model_request_parameters is not None - assert len(model.last_model_request_parameters.builtin_tools) == 1 - assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool - - -async def test_agent_builtin_tools_run_stream_events(): - """Test that Agent.run_stream_events accepts builtin_tools parameter.""" - model = TestModel() - agent = Agent(model=model, builtin_tools=[]) - - # Test with empty builtin_tools - events = [event async for event in agent.run_stream_events('Hello', builtin_tools=[])] - - assert len(events) >= 2 - assert isinstance(events[-1], AgentRunResultEvent) - assert events[-1].result.output == 'success (no tool calls)' - - assert model.last_model_request_parameters is not None - assert model.last_model_request_parameters.builtin_tools == [] + agent = Agent( + model=model, + builtin_tools=[ + WebSearchTool(), + CodeExecutionTool(), + MCPServerTool(id='deepwiki', url='https://mcp.deepwiki.com/mcp'), + MCPServerTool(id='github', url='https://api.githubcopilot.com/mcp'), + ], + ) - # Test with builtin tool - web_search_tool = WebSearchTool() + # Runtime tool with same unique ID should override agent-level tool with pytest.raises(Exception, match='TestModel does not support built-in tools'): - events = [event async for event in agent.run_stream_events('Hello', builtin_tools=[web_search_tool])] + agent.run_sync( + 'Hello', + builtin_tools=[ + WebSearchTool(search_context_size='high'), + MCPServerTool(id='example', url='https://mcp.example.com/mcp'), + MCPServerTool(id='github', url='https://mcp.githubcopilot.com/mcp', authorization_token='token'), + ], + ) assert model.last_model_request_parameters is not None - assert len(model.last_model_request_parameters.builtin_tools) == 1 - assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool + assert model.last_model_request_parameters.builtin_tools == snapshot( + [ + WebSearchTool(search_context_size='high'), + CodeExecutionTool(), + MCPServerTool(id='deepwiki', url='https://mcp.deepwiki.com/mcp'), + MCPServerTool(id='github', url='https://mcp.githubcopilot.com/mcp', authorization_token='token'), + MCPServerTool(id='example', url='https://mcp.example.com/mcp'), + ] + ) From e1b73f8c3dfd42a853bd4ba504c1d60e282d0373 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 24 Oct 2025 02:13:26 +0000 Subject: [PATCH 2/3] docstring --- pydantic_ai_slim/pydantic_ai/builtin_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/builtin_tools.py b/pydantic_ai_slim/pydantic_ai/builtin_tools.py index a30501af59..5559b3124a 100644 --- a/pydantic_ai_slim/pydantic_ai/builtin_tools.py +++ b/pydantic_ai_slim/pydantic_ai/builtin_tools.py @@ -38,7 +38,7 @@ class AbstractBuiltinTool(ABC): def unique_id(self) -> str: """A unique identifier for the builtin tool. - If multiple instances of the same builtin tool can be passed to the model, this should be used to distinguish them. + If multiple instances of the same builtin tool can be passed to the model, subclasses should override this property to allow them to be distinguished. """ return self.kind From 7902bba031f281cdca6e98be05f9bb5d12b25b2e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 24 Oct 2025 02:27:14 +0000 Subject: [PATCH 3/3] fix --- pydantic_ai_slim/pydantic_ai/models/anthropic.py | 2 +- pydantic_ai_slim/pydantic_ai/models/openai.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index c3957170dd..80f3bea6e4 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -596,7 +596,7 @@ async def _map_message(self, messages: list[ModelMessage]) -> tuple[str, list[Be ), ) ) - elif response_part.tool_name == MCPServerTool.kind and isinstance( + elif response_part.tool_name.startswith(MCPServerTool.kind) and isinstance( response_part.content, dict ): # pragma: no branch assistant_content_params.append( diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index fd18cff130..7b8bff1c55 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -1525,7 +1525,7 @@ async def _map_messages( # noqa: C901 elif item.tool_name == ImageGenerationTool.kind: # Image generation result does not need to be sent back, just the `id` off of `BuiltinToolCallPart`. pass - elif item.tool_name == MCPServerTool.kind: # pragma: no branch + elif item.tool_name.startswith(MCPServerTool.kind): # pragma: no branch # MCP call result does not need to be sent back, just the fields off of `BuiltinToolCallPart`. pass elif isinstance(item, FilePart):