Skip to content

Commit 7c372d4

Browse files
author
houguokun
committed
fix: handle duplicate tool names across MCP servers by auto-renaming
When multiple MCP servers have tools with the same name, instead of raising a UserError, the SDK now automatically renames duplicate tools with a server prefix (e.g., 'server_name__tool_name'). This allows users to use multiple MCP servers even when they have overlapping tool names. Changes: - Modified MCPUtil.get_all_function_tools() to detect duplicates and rename them instead of raising an error - Added MCPUtil._rename_tool() helper to properly copy and rename FunctionTool instances while preserving all internal metadata (_tool_origin, _mcp_title, etc.) - Added warning logs to inform users about the renaming - Updated existing tests to reflect the new behavior - Added new tests for duplicate name handling scenarios Fixes #1167 Fixes #464
1 parent da82b2c commit 7c372d4

File tree

3 files changed

+233
-19
lines changed

3 files changed

+233
-19
lines changed

src/agents/mcp/util.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,13 @@ async def get_all_function_tools(
211211
agent: AgentBase,
212212
failure_error_function: ToolErrorFunction | None = default_tool_error_function,
213213
) -> list[Tool]:
214-
"""Get all function tools from a list of MCP servers."""
215-
tools = []
214+
"""Get all function tools from a list of MCP servers.
215+
216+
When multiple MCP servers have tools with the same name, the duplicate tools
217+
are automatically renamed with a server prefix to avoid collisions. A warning
218+
is logged to inform the user about the renaming.
219+
"""
220+
tools: list[Tool] = []
216221
tool_names: set[str] = set()
217222
for server in servers:
218223
server_tools = await cls.get_function_tools(
@@ -223,16 +228,53 @@ async def get_all_function_tools(
223228
failure_error_function=failure_error_function,
224229
)
225230
server_tool_names = {tool.name for tool in server_tools}
226-
if len(server_tool_names & tool_names) > 0:
227-
raise UserError(
228-
f"Duplicate tool names found across MCP servers: "
229-
f"{server_tool_names & tool_names}"
231+
duplicates = server_tool_names & tool_names
232+
if duplicates:
233+
logger.warning(
234+
f"Duplicate tool names found across MCP servers: {duplicates}. "
235+
f"Renaming tools from server '{server.name}' with prefix."
230236
)
231-
tool_names.update(server_tool_names)
232-
tools.extend(server_tools)
237+
renamed_tools: list[Tool] = []
238+
for tool in server_tools:
239+
if tool.name in duplicates:
240+
original_name = tool.name
241+
new_name = f"{server.name}__{original_name}"
242+
# Ensure the new name doesn't also collide
243+
while new_name in tool_names:
244+
new_name = f"{new_name}_"
245+
logger.warning(
246+
f"Renamed MCP tool '{original_name}' from server "
247+
f"'{server.name}' to '{new_name}'"
248+
)
249+
# Create a renamed copy of the tool
250+
renamed_tool = cls._rename_tool(tool, new_name)
251+
renamed_tools.append(renamed_tool)
252+
tool_names.add(new_name)
253+
else:
254+
renamed_tools.append(tool)
255+
tool_names.add(tool.name)
256+
tools.extend(renamed_tools)
257+
else:
258+
tool_names.update(server_tool_names)
259+
tools.extend(server_tools)
233260

234261
return tools
235262

263+
@staticmethod
264+
def _rename_tool(tool: Tool, new_name: str) -> Tool:
265+
"""Create a copy of a tool with a new name.
266+
267+
For FunctionTool instances, uses the built-in __copy__ method to ensure all
268+
internal fields (including _mcp_title, _tool_origin, etc.) are properly copied.
269+
"""
270+
if isinstance(tool, FunctionTool):
271+
renamed = tool.__copy__()
272+
renamed.name = new_name
273+
return renamed
274+
# For other tool types, try to set name directly
275+
tool.name = new_name
276+
return tool
277+
236278
@classmethod
237279
async def get_function_tools(
238280
cls,
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Tests for MCP duplicate tool name handling."""
2+
3+
import pytest
4+
5+
from agents import Agent, FunctionTool, RunContextWrapper
6+
from agents.mcp import MCPServer, MCPUtil
7+
8+
from .helpers import FakeMCPServer
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_get_all_function_tools_with_duplicate_names():
13+
"""Test that duplicate tool names across MCP servers are automatically renamed."""
14+
server1 = FakeMCPServer(server_name="server1")
15+
server1.add_tool("search", {})
16+
server1.add_tool("fetch", {})
17+
18+
server2 = FakeMCPServer(server_name="server2")
19+
server2.add_tool("search", {}) # duplicate name
20+
server2.add_tool("update", {})
21+
22+
servers: list[MCPServer] = [server1, server2]
23+
run_context = RunContextWrapper(context=None)
24+
agent = Agent(name="test_agent", instructions="Test agent")
25+
26+
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
27+
28+
# Should have 4 tools total
29+
assert len(tools) == 4
30+
31+
tool_names = [tool.name for tool in tools]
32+
# Original names from first server should be preserved
33+
assert "search" in tool_names
34+
assert "fetch" in tool_names
35+
# Duplicate from second server should be renamed
36+
assert "server2__search" in tool_names
37+
assert "update" in tool_names
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_get_all_function_tools_with_duplicate_names_three_servers():
42+
"""Test duplicate tool name handling with three servers having the same tool name."""
43+
server1 = FakeMCPServer(server_name="server1")
44+
server1.add_tool("search", {})
45+
46+
server2 = FakeMCPServer(server_name="server2")
47+
server2.add_tool("search", {}) # duplicate
48+
49+
server3 = FakeMCPServer(server_name="server3")
50+
server3.add_tool("search", {}) # another duplicate
51+
52+
servers: list[MCPServer] = [server1, server2, server3]
53+
run_context = RunContextWrapper(context=None)
54+
agent = Agent(name="test_agent", instructions="Test agent")
55+
56+
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
57+
58+
assert len(tools) == 3
59+
tool_names = [tool.name for tool in tools]
60+
assert "search" in tool_names
61+
assert "server2__search" in tool_names
62+
assert "server3__search" in tool_names
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_get_all_function_tools_no_duplicates():
67+
"""Test that non-duplicate tool names are not affected."""
68+
server1 = FakeMCPServer(server_name="server1")
69+
server1.add_tool("search", {})
70+
71+
server2 = FakeMCPServer(server_name="server2")
72+
server2.add_tool("fetch", {}) # no duplicate
73+
74+
servers: list[MCPServer] = [server1, server2]
75+
run_context = RunContextWrapper(context=None)
76+
agent = Agent(name="test_agent", instructions="Test agent")
77+
78+
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
79+
80+
assert len(tools) == 2
81+
tool_names = [tool.name for tool in tools]
82+
assert "search" in tool_names
83+
assert "fetch" in tool_names
84+
# Should not have any prefixed names
85+
assert "server1__search" not in tool_names
86+
assert "server2__fetch" not in tool_names
87+
88+
89+
@pytest.mark.asyncio
90+
async def test_get_all_function_tools_preserves_mcp_origin():
91+
"""Test that renamed tools preserve their MCP origin metadata."""
92+
server1 = FakeMCPServer(server_name="server1")
93+
server1.add_tool("search", {})
94+
95+
server2 = FakeMCPServer(server_name="server2")
96+
server2.add_tool("search", {}) # duplicate
97+
98+
servers: list[MCPServer] = [server1, server2]
99+
run_context = RunContextWrapper(context=None)
100+
agent = Agent(name="test_agent", instructions="Test agent")
101+
102+
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
103+
104+
# Find the renamed tool
105+
renamed_tool = next((t for t in tools if t.name == "server2__search"), None)
106+
assert renamed_tool is not None
107+
assert isinstance(renamed_tool, FunctionTool)
108+
# Check that MCP origin is preserved
109+
assert renamed_tool._tool_origin is not None
110+
assert renamed_tool._tool_origin.mcp_server_name == "server2"
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_renamed_tool_can_be_invoked():
115+
"""Test that renamed tools can still be invoked successfully."""
116+
server1 = FakeMCPServer(server_name="server1")
117+
server1.add_tool("search", {})
118+
119+
server2 = FakeMCPServer(server_name="server2")
120+
server2.add_tool("search", {}) # duplicate
121+
122+
servers: list[MCPServer] = [server1, server2]
123+
run_context = RunContextWrapper(context=None)
124+
agent = Agent(name="test_agent", instructions="Test agent")
125+
126+
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
127+
128+
# Find the renamed tool and invoke it
129+
renamed_tool = next((t for t in tools if t.name == "server2__search"), None)
130+
assert renamed_tool is not None
131+
assert isinstance(renamed_tool, FunctionTool)
132+
133+
# The tool should be invocable
134+
assert renamed_tool.on_invoke_tool is not None

tests/mcp/test_runner_calls_mcp.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
ModelBehaviorError,
99
RunContextWrapper,
1010
Runner,
11-
UserError,
1211
default_tool_error_function,
1312
)
1413
from agents.exceptions import AgentsException
@@ -125,14 +124,14 @@ async def test_runner_works_with_multiple_mcp_servers(streaming: bool):
125124

126125
@pytest.mark.asyncio
127126
@pytest.mark.parametrize("streaming", [False, True])
128-
async def test_runner_errors_when_mcp_tools_clash(streaming: bool):
129-
"""Test that the runner errors when multiple servers have the same tool name."""
127+
async def test_runner_renames_mcp_tools_when_names_clash(streaming: bool):
128+
"""Test that the runner auto-renames tools when multiple servers have same name."""
130129
server1 = FakeMCPServer()
131130
server1.add_tool("test_tool_1", {})
132131
server1.add_tool("test_tool_2", {})
133132

134133
server2 = FakeMCPServer()
135-
server2.add_tool("test_tool_2", {})
134+
server2.add_tool("test_tool_2", {}) # duplicate name
136135
server2.add_tool("test_tool_3", {})
137136

138137
model = FakeModel()
@@ -145,19 +144,58 @@ async def test_runner_errors_when_mcp_tools_clash(streaming: bool):
145144
model.add_multiple_turn_outputs(
146145
[
147146
# First turn: a message and tool call
147+
# test_tool_3 is unique to server2, so it should work without renaming
148148
[get_text_message("a_message"), get_function_tool_call("test_tool_3", "")],
149149
# Second turn: text message
150150
[get_text_message("done")],
151151
]
152152
)
153153

154-
with pytest.raises(UserError):
155-
if streaming:
156-
result = Runner.run_streamed(agent, input="user_message")
157-
async for _ in result.stream_events():
158-
pass
159-
else:
160-
await Runner.run(agent, input="user_message")
154+
if streaming:
155+
result = Runner.run_streamed(agent, input="user_message")
156+
async for _ in result.stream_events():
157+
pass
158+
else:
159+
await Runner.run(agent, input="user_message")
160+
161+
# server2's test_tool_3 should be called successfully (no rename needed)
162+
assert server2.tool_calls == ["test_tool_3"]
163+
164+
165+
@pytest.mark.asyncio
166+
@pytest.mark.parametrize("streaming", [False, True])
167+
async def test_runner_renamed_mcp_tool_can_be_called(streaming: bool):
168+
"""Test that renamed MCP tools can still be invoked by the model."""
169+
server1 = FakeMCPServer(server_name="server1")
170+
server1.add_tool("search", {})
171+
172+
server2 = FakeMCPServer(server_name="server2")
173+
server2.add_tool("search", {}) # duplicate name
174+
175+
model = FakeModel()
176+
agent = Agent(
177+
name="test",
178+
model=model,
179+
mcp_servers=[server1, server2],
180+
)
181+
182+
model.add_multiple_turn_outputs(
183+
[
184+
# The model should use the renamed tool name
185+
[get_text_message("a_message"), get_function_tool_call("server2__search", "")],
186+
[get_text_message("done")],
187+
]
188+
)
189+
190+
if streaming:
191+
result = Runner.run_streamed(agent, input="user_message")
192+
async for _ in result.stream_events():
193+
pass
194+
else:
195+
await Runner.run(agent, input="user_message")
196+
197+
# The renamed tool from server2 should be called
198+
assert server2.tool_calls == ["search"]
161199

162200

163201
class Foo(BaseModel):

0 commit comments

Comments
 (0)