豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 86683e0

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 86683e0

File tree

3 files changed

+268
-19
lines changed

3 files changed

+268
-19
lines changed

src/agents/mcp/util.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
)
3838
from ..tool_context import ToolContext
3939
from ..tracing import FunctionSpanData, get_current_span, mcp_tools_span
40+
from ..util._transforms import transform_string_function_style
4041
from ..util._types import MaybeAwaitable
4142

4243
if TYPE_CHECKING:
@@ -211,8 +212,13 @@ async def get_all_function_tools(
211212
agent: AgentBase,
212213
failure_error_function: ToolErrorFunction | None = default_tool_error_function,
213214
) -> list[Tool]:
214-
"""Get all function tools from a list of MCP servers."""
215-
tools = []
215+
"""Get all function tools from a list of MCP servers.
216+
217+
When multiple MCP servers have tools with the same name, the duplicate tools
218+
are automatically renamed with a server prefix to avoid collisions. A warning
219+
is logged to inform the user about the renaming.
220+
"""
221+
tools: list[Tool] = []
216222
tool_names: set[str] = set()
217223
for server in servers:
218224
server_tools = await cls.get_function_tools(
@@ -223,16 +229,67 @@ async def get_all_function_tools(
223229
failure_error_function=failure_error_function,
224230
)
225231
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}"
232+
duplicates = server_tool_names & tool_names
233+
if duplicates:
234+
logger.warning(
235+
f"Duplicate tool names found across MCP servers: {duplicates}. "
236+
f"Renaming tools from server '{server.name}' with prefix."
230237
)
231-
tool_names.update(server_tool_names)
232-
tools.extend(server_tools)
238+
renamed_tools: list[Tool] = []
239+
for tool in server_tools:
240+
if tool.name in duplicates:
241+
original_name = tool.name
242+
new_name = cls._build_renamed_tool_name(
243+
server.name,
244+
original_name,
245+
tool_names,
246+
)
247+
logger.warning(
248+
f"Renamed MCP tool '{original_name}' from server "
249+
f"'{server.name}' to '{new_name}'"
250+
)
251+
# Create a renamed copy of the tool
252+
renamed_tool = cls._rename_tool(tool, new_name)
253+
renamed_tools.append(renamed_tool)
254+
tool_names.add(new_name)
255+
else:
256+
renamed_tools.append(tool)
257+
tool_names.add(tool.name)
258+
tools.extend(renamed_tools)
259+
else:
260+
tool_names.update(server_tool_names)
261+
tools.extend(server_tools)
233262

234263
return tools
235264

265+
@staticmethod
266+
def _build_renamed_tool_name(
267+
server_name: str,
268+
original_name: str,
269+
existing_names: set[str],
270+
) -> str:
271+
"""Build a function-calling-safe renamed tool name for duplicate MCP tools."""
272+
normalized_server_name = transform_string_function_style(server_name)
273+
new_name = f"{normalized_server_name}__{original_name}"
274+
while new_name in existing_names:
275+
new_name = f"{new_name}_"
276+
return new_name
277+
278+
@staticmethod
279+
def _rename_tool(tool: Tool, new_name: str) -> Tool:
280+
"""Create a copy of a tool with a new name.
281+
282+
For FunctionTool instances, uses the built-in __copy__ method to ensure all
283+
internal fields (including _mcp_title, _tool_origin, etc.) are properly copied.
284+
"""
285+
if isinstance(tool, FunctionTool):
286+
renamed = tool.__copy__()
287+
renamed.name = new_name
288+
return renamed
289+
# For other tool types, try to set name directly
290+
tool.name = new_name
291+
return tool
292+
236293
@classmethod
237294
async def get_function_tools(
238295
cls,
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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_normalizes_server_name_in_renamed_tool():
67+
"""Test renamed tool names use a function-calling-safe server prefix."""
68+
server1 = FakeMCPServer(server_name="Primary Server")
69+
server1.add_tool("search", {})
70+
71+
server2 = FakeMCPServer(server_name="Secondary-Server")
72+
server2.add_tool("search", {}) # 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+
tool_names = [tool.name for tool in tools]
81+
assert "search" in tool_names
82+
assert "secondary_server__search" in tool_names
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_get_all_function_tools_no_duplicates():
87+
"""Test that non-duplicate tool names are not affected."""
88+
server1 = FakeMCPServer(server_name="server1")
89+
server1.add_tool("search", {})
90+
91+
server2 = FakeMCPServer(server_name="server2")
92+
server2.add_tool("fetch", {}) # no duplicate
93+
94+
servers: list[MCPServer] = [server1, server2]
95+
run_context = RunContextWrapper(context=None)
96+
agent = Agent(name="test_agent", instructions="Test agent")
97+
98+
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
99+
100+
assert len(tools) == 2
101+
tool_names = [tool.name for tool in tools]
102+
assert "search" in tool_names
103+
assert "fetch" in tool_names
104+
# Should not have any prefixed names
105+
assert "server1__search" not in tool_names
106+
assert "server2__fetch" not in tool_names
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_get_all_function_tools_preserves_mcp_origin():
111+
"""Test that renamed tools preserve their MCP origin metadata."""
112+
server1 = FakeMCPServer(server_name="server1")
113+
server1.add_tool("search", {})
114+
115+
server2 = FakeMCPServer(server_name="server2")
116+
server2.add_tool("search", {}) # duplicate
117+
118+
servers: list[MCPServer] = [server1, server2]
119+
run_context = RunContextWrapper(context=None)
120+
agent = Agent(name="test_agent", instructions="Test agent")
121+
122+
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
123+
124+
# Find the renamed tool
125+
renamed_tool = next((t for t in tools if t.name == "server2__search"), None)
126+
assert renamed_tool is not None
127+
assert isinstance(renamed_tool, FunctionTool)
128+
# Check that MCP origin is preserved
129+
assert renamed_tool._tool_origin is not None
130+
assert renamed_tool._tool_origin.mcp_server_name == "server2"
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_renamed_tool_can_be_invoked():
135+
"""Test that renamed tools can still be invoked successfully."""
136+
server1 = FakeMCPServer(server_name="server1")
137+
server1.add_tool("search", {})
138+
139+
server2 = FakeMCPServer(server_name="server2")
140+
server2.add_tool("search", {}) # duplicate
141+
142+
servers: list[MCPServer] = [server1, server2]
143+
run_context = RunContextWrapper(context=None)
144+
agent = Agent(name="test_agent", instructions="Test agent")
145+
146+
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
147+
148+
# Find the renamed tool and invoke it
149+
renamed_tool = next((t for t in tools if t.name == "server2__search"), None)
150+
assert renamed_tool is not None
151+
assert isinstance(renamed_tool, FunctionTool)
152+
153+
# The tool should be invocable
154+
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)