From 9b1b238ecdacac564e8f5a7bace111140819f3e4 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Tue, 3 Mar 2026 11:10:17 -0800 Subject: [PATCH 1/3] 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> --- .../packages/core/agent_framework/_agents.py | 3 +- .../packages/core/tests/core/test_agents.py | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 8f477f9223..6f01b6ee19 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -1051,10 +1051,11 @@ async def _prepare_run_context( else: final_tools.append(tool) # type: ignore + existing_names = {t.name for t in final_tools} 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..d70267b872 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -755,6 +755,54 @@ 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. + + Reproduces https://github.com/microsoft/agent-framework/issues/4381 where AG-UI's + collect_server_tools pre-expands MCP functions on turn 2 (when server is connected), + and _prepare_run_context unconditionally appends them again from self.mcp_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.""" From bc0d35acbf043161a345fbd6dc86555510dca02f Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Tue, 3 Mar 2026 11:24:21 -0800 Subject: [PATCH 2/3] mypy fix --- python/packages/core/agent_framework/_agents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 6f01b6ee19..cd2dc7bfc7 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -1051,7 +1051,7 @@ async def _prepare_run_context( else: final_tools.append(tool) # type: ignore - existing_names = {t.name for t in final_tools} + 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) From 7b2edf87cd465eae7bf001233e0a9b799f8d74a1 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Tue, 3 Mar 2026 15:19:06 -0800 Subject: [PATCH 3/3] Remove issue-specific references from test docstring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/tests/core/test_agents.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index d70267b872..c8d2d9bf8b 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -756,12 +756,7 @@ async def test_chat_agent_with_local_mcp_tools(client: SupportsChatGetResponse) 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. - - Reproduces https://github.com/microsoft/agent-framework/issues/4381 where AG-UI's - collect_server_tools pre-expands MCP functions on turn 2 (when server is connected), - and _prepare_run_context unconditionally appends them again from self.mcp_tools. - """ + """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