From b0ac3939c15bd7763b60d5c5ebd5209f79c9367a Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:21:01 -0800 Subject: [PATCH] Python: Fix MCP tools duplicated on second turn when runtime tools are present (#4432) * Fix MCP tools duplicated on second turn when runtime tools are present When AG-UI's collect_server_tools pre-expands MCP functions on turn 2 (after the MCP server is connected), _prepare_run_context unconditionally appends them again from self.mcp_tools, duplicating every MCP tool. Skip MCP functions whose names already exist in the final tool list, following the same name-based dedup pattern used in _merge_options. Fixes #4381 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * mypy fix * Remove issue-specific references from test docstring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_agents.py | 3 +- .../packages/core/tests/core/test_agents.py | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 8f477f9223..cd2dc7bfc7 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -1051,10 +1051,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] else: final_tools.append(tool) # type: ignore + existing_names = {name for t in final_tools if (name := _get_tool_name(t)) is not None} for mcp_server in self.mcp_tools: if not mcp_server.is_connected: await self._async_exit_stack.enter_async_context(mcp_server) - final_tools.extend(mcp_server.functions) + final_tools.extend(f for f in mcp_server.functions if f.name not in existing_names) # Merge runtime kwargs into additional_function_arguments so they're available # in function middleware context and tool invocation. diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index a857682fe2..c8d2d9bf8b 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -755,6 +755,49 @@ async def test_chat_agent_with_local_mcp_tools(client: SupportsChatGetResponse) pass +async def test_mcp_tools_not_duplicated_when_passed_as_runtime_tools(chat_client_base: Any) -> None: + """Test that MCP tool functions from self.mcp_tools are not duplicated when already present in runtime tools.""" + captured_options: list[dict[str, Any]] = [] + + original_inner = chat_client_base._inner_get_response + + async def capturing_inner( + *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any + ) -> ChatResponse: + captured_options.append(dict(options)) + return await original_inner(messages=messages, options=options, **kwargs) + + chat_client_base._inner_get_response = capturing_inner + + # Create FunctionTool instances that simulate expanded MCP functions + mcp_func_a = FunctionTool(func=lambda: "a", name="tool_a", description="Tool A") + mcp_func_b = FunctionTool(func=lambda: "b", name="tool_b", description="Tool B") + + # Create a mock MCP tool that is already connected (simulates turn 2) + mock_mcp_tool = MagicMock(spec=MCPTool) + mock_mcp_tool.is_connected = True + mock_mcp_tool.functions = [mcp_func_a, mcp_func_b] + mock_mcp_tool.__aenter__ = AsyncMock(return_value=mock_mcp_tool) + mock_mcp_tool.__aexit__ = AsyncMock(return_value=None) + + # Agent has the MCP tool in its constructor (stored in self.mcp_tools) + agent = Agent(client=chat_client_base, name="TestAgent", tools=[mock_mcp_tool]) + + # Simulate AG-UI turn 2: pass already-expanded MCP functions + a client tool as runtime tools + client_tool = FunctionTool(func=lambda: "client", name="client_tool", description="Client tool") + runtime_tools = [mcp_func_a, mcp_func_b, client_tool] + + await agent.run("hello", tools=runtime_tools) + + # Verify the chat client received each tool exactly once + assert len(captured_options) >= 1 + tool_names = [t.name for t in captured_options[0]["tools"]] + assert tool_names.count("tool_a") == 1, f"tool_a duplicated: {tool_names}" + assert tool_names.count("tool_b") == 1, f"tool_b duplicated: {tool_names}" + assert "client_tool" in tool_names + assert len(tool_names) == 3 + + async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None: """Verify tool execution receives 'session' inside **kwargs when function is called by client."""