Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ class MCPConfig(TypedDict):
default_tool_error_function.
"""

include_server_in_tool_names: NotRequired[bool]
"""If True, MCP tool names exposed to the model are prefixed with the server name
(e.g. ``my_server__my_tool``) so that tools from different servers with the same
name do not collide. The original MCP tool name is still used when invoking the
server. Defaults to False.
"""


@dataclass
class AgentBase(Generic[TContext]):
Expand Down Expand Up @@ -186,12 +193,14 @@ async def get_mcp_tools(self, run_context: RunContextWrapper[TContext]) -> list[
failure_error_function = self.mcp_config.get(
"failure_error_function", default_tool_error_function
)
include_server_in_tool_names = self.mcp_config.get("include_server_in_tool_names", False)
return await MCPUtil.get_all_function_tools(
self.mcp_servers,
convert_schemas_to_strict,
run_context,
self,
failure_error_function=failure_error_function,
include_server_in_tool_names=include_server_in_tool_names,
)

async def get_all_tools(self, run_context: RunContextWrapper[TContext]) -> list[Tool]:
Expand Down
39 changes: 31 additions & 8 deletions src/agents/mcp/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import functools
import inspect
import json
import re
from collections.abc import Awaitable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Protocol, Union
Expand Down Expand Up @@ -174,6 +175,19 @@ def create_static_tool_filter(
return filter_dict


_SERVER_NAME_SANITIZE_RE = re.compile(r"[^a-zA-Z0-9_]")


def _sanitize_server_name(name: str) -> str:
"""Sanitize an MCP server name so it is safe for use as a tool name prefix.

Replaces any character that is not alphanumeric or underscore with an underscore.
Falls back to ``server`` if the result would be empty.
"""
sanitized = _SERVER_NAME_SANITIZE_RE.sub("_", name).strip("_")
return sanitized or "server"


class MCPUtil:
"""Set of utilities for interop between MCP and Agents SDK tools."""

Expand Down Expand Up @@ -207,9 +221,10 @@ async def get_all_function_tools(
run_context: RunContextWrapper[Any],
agent: AgentBase,
failure_error_function: ToolErrorFunction | None = default_tool_error_function,
include_server_in_tool_names: bool = False,
) -> list[Tool]:
"""Get all function tools from a list of MCP servers."""
tools = []
tools: list[Tool] = []
tool_names: set[str] = set()
for server in servers:
server_tools = await cls.get_function_tools(
Expand All @@ -219,13 +234,21 @@ async def get_all_function_tools(
agent,
failure_error_function=failure_error_function,
)
server_tool_names = {tool.name for tool in server_tools}
if len(server_tool_names & tool_names) > 0:
raise UserError(
f"Duplicate tool names found across MCP servers: "
f"{server_tool_names & tool_names}"
)
tool_names.update(server_tool_names)

if include_server_in_tool_names:
prefix = _sanitize_server_name(server.name)
for tool in server_tools:
if isinstance(tool, FunctionTool):
tool.name = f"{prefix}__{tool.name}"
Comment on lines +238 to +242
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect collisions after server-name prefixing

When include_server_in_tool_names is enabled, tool names are rewritten using a sanitized server prefix but this branch never validates that the rewritten names are unique. For example, servers named a-b and a_b both sanitize to a_b, so run becomes a_b__run for both servers. Downstream function-tool lookup is last-wins, so one tool silently shadows the other and dispatch can target the wrong server instead of raising a UserError like the default path does.

Useful? React with 👍 / 👎.

else:
server_tool_names = {tool.name for tool in server_tools}
if len(server_tool_names & tool_names) > 0:
raise UserError(
f"Duplicate tool names found across MCP servers: "
f"{server_tool_names & tool_names}"
)

tool_names.update(tool.name for tool in server_tools)
tools.extend(server_tools)

return tools
Expand Down
124 changes: 124 additions & 0 deletions tests/mcp/test_mcp_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1455,3 +1455,127 @@ def test_to_function_tool_description_falls_back_to_mcp_title():

assert function_tool.description == "Search Docs"
assert function_tool._mcp_title == "Search Docs"


@pytest.mark.asyncio
async def test_duplicate_tool_names_raises_by_default():
"""Default behavior: duplicate tool names across servers raises UserError."""
server_a = FakeMCPServer(server_name="server_a")
server_a.add_tool("run", {"type": "object", "properties": {}})

server_b = FakeMCPServer(server_name="server_b")
server_b.add_tool("run", {"type": "object", "properties": {}})

agent = Agent(name="test", instructions="test")
run_context = RunContextWrapper(context=None)

with pytest.raises(AgentsException, match="Duplicate tool names"):
await MCPUtil.get_all_function_tools(
[server_a, server_b],
convert_schemas_to_strict=False,
run_context=run_context,
agent=agent,
)


@pytest.mark.asyncio
async def test_include_server_in_tool_names_avoids_collision():
"""With include_server_in_tool_names=True, duplicate names are prefixed and no error."""
server_a = FakeMCPServer(server_name="server_a")
server_a.add_tool("run", {"type": "object", "properties": {}})

server_b = FakeMCPServer(server_name="server_b")
server_b.add_tool("run", {"type": "object", "properties": {}})

agent = Agent(name="test", instructions="test")
run_context = RunContextWrapper(context=None)

tools = await MCPUtil.get_all_function_tools(
[server_a, server_b],
convert_schemas_to_strict=False,
run_context=run_context,
agent=agent,
include_server_in_tool_names=True,
)

tool_names = [t.name for t in tools]
assert "server_a__run" in tool_names
assert "server_b__run" in tool_names
assert len(tool_names) == 2


@pytest.mark.asyncio
async def test_include_server_in_tool_names_invokes_with_original_name():
"""Prefixed tools still invoke the MCP server using the original tool name."""
server = FakeMCPServer(server_name="my_server")
server.add_tool("do_thing", {"type": "object", "properties": {}})

agent = Agent(name="test", instructions="test")
run_context = RunContextWrapper(context=None)

tools = await MCPUtil.get_all_function_tools(
[server],
convert_schemas_to_strict=False,
run_context=run_context,
agent=agent,
include_server_in_tool_names=True,
)

assert len(tools) == 1
func_tool = tools[0]
assert isinstance(func_tool, FunctionTool)
assert func_tool.name == "my_server__do_thing"

# Invoke the tool and verify the server received the original name.
tool_context = ToolContext(
context=None,
tool_name="my_server__do_thing",
tool_call_id="test_call",
tool_arguments="{}",
)
await func_tool.on_invoke_tool(tool_context, "{}")
assert server.tool_calls == ["do_thing"]


@pytest.mark.asyncio
async def test_include_server_in_tool_names_sanitizes_server_name():
"""Server names with special characters are sanitized for the prefix."""
server = FakeMCPServer(server_name="my-cool.server/v2")
server.add_tool("action", {"type": "object", "properties": {}})

agent = Agent(name="test", instructions="test")
run_context = RunContextWrapper(context=None)

tools = await MCPUtil.get_all_function_tools(
[server],
convert_schemas_to_strict=False,
run_context=run_context,
agent=agent,
include_server_in_tool_names=True,
)

func_tool = tools[0]
assert isinstance(func_tool, FunctionTool)
assert func_tool.name == "my_cool_server_v2__action"


@pytest.mark.asyncio
async def test_include_server_in_tool_names_empty_server_name_fallback():
"""Empty or all-special-character server names fall back to 'server'."""
server = FakeMCPServer(server_name="---")
server.add_tool("action", {"type": "object", "properties": {}})

agent = Agent(name="test", instructions="test")
run_context = RunContextWrapper(context=None)

tools = await MCPUtil.get_all_function_tools(
[server],
convert_schemas_to_strict=False,
run_context=run_context,
agent=agent,
include_server_in_tool_names=True,
)

func_tool = tools[0]
assert isinstance(func_tool, FunctionTool)
assert func_tool.name == "server__action"