diff --git a/docs/mcp.md b/docs/mcp.md index 4990585dd5..e855da0c81 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -54,6 +54,31 @@ Notes: - Server-level `failure_error_function` overrides `Agent.mcp_config["failure_error_function"]` for that server. - `include_server_in_tool_names` is opt-in. When enabled, each local MCP tool is exposed to the model with a deterministic server-prefixed name, which helps avoid collisions when multiple MCP servers publish tools with the same name. Generated names are ASCII-safe, stay within the function-tool name length limit, and avoid existing local function tool and enabled handoff names on the same agent. The SDK still invokes the original MCP tool name on the original server. +## Disambiguating duplicate MCP tool names + +When the same tool name is published by more than one MCP server (for example, both a GitHub and a Linear server expose `create_issue`), the agent run fails with `Duplicate tool names found across MCP servers`. If you do not want to opt in to the auto-generated `include_server_in_tool_names` scheme, set a custom `tool_name_prefix` on each individual server. The SDK exposes tools to the model as `f"{prefix}_{original_name}"` and strips the prefix before dispatching `call_tool()` to the upstream server, so the underlying MCP server only ever sees its original tool names. + +```python +from agents import Agent +from agents.mcp import MCPServerStdio + +github = MCPServerStdio( + params={"command": "github-mcp"}, + tool_name_prefix="gh", +) +linear = MCPServerStdio( + params={"command": "linear-mcp"}, + tool_name_prefix="ln", +) + +agent = Agent( + name="Assistant", + mcp_servers=[github, linear], +) +``` + +`tool_name_prefix` is keyword-only, can be combined with `tool_filter`, and respects the 64-character tool-name limit – `list_tools()` raises a `UserError` early if a prefixed name would exceed it. + ## Shared patterns across transports After you choose a transport, most integrations need the same follow-up decisions: diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 268b0893da..51ad92496a 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -220,6 +220,9 @@ class _UnsetType: ) +MCP_TOOL_NAME_MAX_LENGTH = 64 + + class MCPServer(abc.ABC): """Base class for Model Context Protocol servers.""" @@ -229,6 +232,8 @@ def __init__( require_approval: RequireApprovalSetting = None, failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, + *, + tool_name_prefix: str | None = None, ): """ Args: @@ -248,6 +253,13 @@ def __init__( SDK default) will be used. tool_meta_resolver: Optional callable that produces MCP request metadata (`_meta`) for tool calls. It is invoked by the Agents SDK before calling `call_tool`. + tool_name_prefix: Optional custom prefix to apply to every tool name returned by this + server. When set, `list_tools()` returns tools named `f"{prefix}_{original_name}"`, + and `call_tool()` automatically strips the prefix before dispatching to the + upstream server. This is useful for disambiguating tools when multiple MCP servers + publish tools with the same name (for example, two servers that both expose + `create_issue`). The prefix plus underscore plus the longest tool name must not + exceed 64 characters or a UserError is raised at `list_tools()` time. """ self.use_structured_content = use_structured_content self._needs_approval_policy = self._normalize_needs_approval( @@ -255,6 +267,42 @@ def __init__( ) self._failure_error_function = failure_error_function self.tool_meta_resolver = tool_meta_resolver + self.tool_name_prefix = tool_name_prefix + + def _apply_tool_name_prefix(self, tools: list[MCPTool]) -> list[MCPTool]: + """Return a new list of tools with `tool_name_prefix` applied to each tool name. + + Validates that no prefixed name exceeds the MCP tool-name length limit. Returns the + original list unchanged when no prefix is configured. + """ + if not self.tool_name_prefix: + return tools + + prefix = self.tool_name_prefix + prefixed: list[MCPTool] = [] + for tool in tools: + new_name = f"{prefix}_{tool.name}" + if len(new_name) > MCP_TOOL_NAME_MAX_LENGTH: + raise UserError( + f"MCP tool name '{new_name}' on server '{self.name}' exceeds the " + f"{MCP_TOOL_NAME_MAX_LENGTH}-character limit after applying " + f"tool_name_prefix='{prefix}'. Choose a shorter prefix." + ) + prefixed.append(tool.model_copy(update={"name": new_name})) + return prefixed + + def _strip_tool_name_prefix(self, tool_name: str) -> str: + """Strip `tool_name_prefix` from a prefixed tool name. + + Returns the input unchanged when no prefix is configured or when the input does not + begin with the configured prefix (e.g. an unprefixed name passed through directly). + """ + if not self.tool_name_prefix: + return tool_name + prefix_with_sep = f"{self.tool_name_prefix}_" + if tool_name.startswith(prefix_with_sep): + return tool_name[len(prefix_with_sep) :] + return tool_name @abc.abstractmethod async def connect(self): @@ -512,7 +560,10 @@ async def _needs_approval( return _needs_approval if isinstance(policy, dict): - return bool(policy.get(tool.name, False)) + # Look up the policy by the upstream (un-prefixed) tool name so that + # `require_approval={"create_issue": "always"}` keeps working even when + # `tool_name_prefix` is set. + return bool(policy.get(self._strip_tool_name_prefix(tool.name), False)) return bool(policy) @@ -544,6 +595,8 @@ def __init__( require_approval: RequireApprovalSetting = None, failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, + *, + tool_name_prefix: str | None = None, ): """ Args: @@ -576,12 +629,19 @@ def __init__( SDK default) will be used. tool_meta_resolver: Optional callable that produces MCP request metadata (`_meta`) for tool calls. It is invoked by the Agents SDK before calling `call_tool`. + tool_name_prefix: Optional custom prefix applied to every tool name returned by this + server. When set, `list_tools()` returns tools named + `f"{prefix}_{original_name}"`, and `call_tool()` strips the prefix before + dispatching upstream so the underlying MCP server only sees the original name. + Useful for disambiguating tools when multiple MCP servers publish tools with the + same name. """ super().__init__( use_structured_content=use_structured_content, require_approval=require_approval, failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, + tool_name_prefix=tool_name_prefix, ) self.session: ClientSession | None = None self.exit_stack: AsyncExitStack = AsyncExitStack() @@ -841,11 +901,15 @@ async def list_tools( self._cache_dirty = False tools = self._tools_list - # Filter tools based on tool_filter + # Filter tools based on tool_filter. The filter sees the upstream (unprefixed) + # tool names so users can author allow/block lists in terms of the names the MCP + # server actually publishes. filtered_tools = tools if self.tool_filter is not None: filtered_tools = await self._apply_tool_filter(filtered_tools, run_context, agent) - return filtered_tools + # Apply tool_name_prefix (if any) last so that prefixed names are what the rest of + # the Agents SDK sees while the cache and upstream calls keep the original names. + return self._apply_tool_name_prefix(filtered_tools) except httpx.HTTPStatusError as e: status_code = e.response.status_code raise UserError( @@ -869,17 +933,19 @@ async def call_tool( session = self.session assert session is not None + # Strip tool_name_prefix (if any) so the upstream MCP server sees the original name. + upstream_tool_name = self._strip_tool_name_prefix(tool_name) try: - self._validate_required_parameters(tool_name=tool_name, arguments=arguments) + self._validate_required_parameters(tool_name=upstream_tool_name, arguments=arguments) if meta is None: return await self._run_with_retries( lambda: self._maybe_serialize_request( - lambda: session.call_tool(tool_name, arguments) + lambda: session.call_tool(upstream_tool_name, arguments) ) ) return await self._run_with_retries( lambda: self._maybe_serialize_request( - lambda: session.call_tool(tool_name, arguments, meta=meta) + lambda: session.call_tool(upstream_tool_name, arguments, meta=meta) ) ) except httpx.HTTPStatusError as e: @@ -1108,6 +1174,8 @@ def __init__( require_approval: RequireApprovalSetting = None, failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, + *, + tool_name_prefix: str | None = None, ): """Create a new MCP server based on the stdio transport. @@ -1145,6 +1213,11 @@ def __init__( SDK default) will be used. tool_meta_resolver: Optional callable that produces MCP request metadata (`_meta`) for tool calls. It is invoked by the Agents SDK before calling `call_tool`. + tool_name_prefix: Optional custom prefix applied to every tool name returned by this + server. When set, `list_tools()` returns tools named + `f"{prefix}_{original_name}"`, and `call_tool()` strips the prefix before + dispatching upstream. Useful for disambiguating tools when multiple MCP servers + publish tools with the same name. """ super().__init__( cache_tools_list=cache_tools_list, @@ -1157,6 +1230,7 @@ def __init__( require_approval=require_approval, failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, + tool_name_prefix=tool_name_prefix, ) self.params = StdioServerParameters( @@ -1229,6 +1303,8 @@ def __init__( require_approval: RequireApprovalSetting = None, failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, + *, + tool_name_prefix: str | None = None, ): """Create a new MCP server based on the HTTP with SSE transport. @@ -1268,6 +1344,11 @@ def __init__( SDK default) will be used. tool_meta_resolver: Optional callable that produces MCP request metadata (`_meta`) for tool calls. It is invoked by the Agents SDK before calling `call_tool`. + tool_name_prefix: Optional custom prefix applied to every tool name returned by this + server. When set, `list_tools()` returns tools named + `f"{prefix}_{original_name}"`, and `call_tool()` strips the prefix before + dispatching upstream. Useful for disambiguating tools when multiple MCP servers + publish tools with the same name. """ super().__init__( cache_tools_list=cache_tools_list, @@ -1280,6 +1361,7 @@ def __init__( require_approval=require_approval, failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, + tool_name_prefix=tool_name_prefix, ) self.params = params @@ -1364,6 +1446,8 @@ def __init__( require_approval: RequireApprovalSetting = None, failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, + *, + tool_name_prefix: str | None = None, ): """Create a new MCP server based on the Streamable HTTP transport. @@ -1404,6 +1488,11 @@ def __init__( SDK default) will be used. tool_meta_resolver: Optional callable that produces MCP request metadata (`_meta`) for tool calls. It is invoked by the Agents SDK before calling `call_tool`. + tool_name_prefix: Optional custom prefix applied to every tool name returned by this + server. When set, `list_tools()` returns tools named + `f"{prefix}_{original_name}"`, and `call_tool()` strips the prefix before + dispatching upstream. Useful for disambiguating tools when multiple MCP servers + publish tools with the same name. """ super().__init__( cache_tools_list=cache_tools_list, @@ -1416,6 +1505,7 @@ def __init__( require_approval=require_approval, failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, + tool_name_prefix=tool_name_prefix, ) self.params = params @@ -1570,8 +1660,10 @@ async def call_tool( if not self.session: raise UserError("Server not initialized. Make sure you call `connect()` first.") + # Strip tool_name_prefix (if any) so the upstream MCP server sees the original name. + upstream_tool_name = self._strip_tool_name_prefix(tool_name) try: - self._validate_required_parameters(tool_name=tool_name, arguments=arguments) + self._validate_required_parameters(tool_name=upstream_tool_name, arguments=arguments) retries_used = 0 first_attempt = True while True: @@ -1582,7 +1674,7 @@ async def call_tool( ) try: result, used_isolated_retry = await self._call_tool_with_isolated_retry( - tool_name, + upstream_tool_name, arguments, meta, allow_isolated_retry=allow_isolated_retry, diff --git a/tests/mcp/helpers.py b/tests/mcp/helpers.py index ef820fad99..799ddf812a 100644 --- a/tests/mcp/helpers.py +++ b/tests/mcp/helpers.py @@ -76,12 +76,15 @@ def __init__( require_approval: object | None = None, failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET, tool_meta_resolver: MCPToolMetaResolver | None = None, + *, + tool_name_prefix: str | None = None, ): super().__init__( use_structured_content=False, require_approval=require_approval, # type: ignore[arg-type] failure_error_function=failure_error_function, tool_meta_resolver=tool_meta_resolver, + tool_name_prefix=tool_name_prefix, ) self.tools: list[MCPTool] = tools or [] self.tool_calls: list[str] = [] @@ -109,7 +112,9 @@ async def list_tools(self, run_context=None, agent=None): filter_server = _TestFilterServer(self.tool_filter, self.name) tools = await filter_server._apply_tool_filter(tools, run_context, agent) - return tools + # Apply tool_name_prefix (if configured) using the real implementation so the + # FakeMCPServer behaves the same way as the production server classes. + return self._apply_tool_name_prefix(tools) async def call_tool( self, @@ -117,6 +122,9 @@ async def call_tool( arguments: dict[str, Any] | None, meta: dict[str, Any] | None = None, ) -> CallToolResult: + # Strip tool_name_prefix (if configured) so the recorded call matches what the + # upstream MCP server would actually receive. + tool_name = self._strip_tool_name_prefix(tool_name) self.tool_calls.append(tool_name) self.tool_results.append(f"result_{tool_name}_{json.dumps(arguments)}") self.tool_metas.append(meta) diff --git a/tests/mcp/test_tool_name_prefix.py b/tests/mcp/test_tool_name_prefix.py new file mode 100644 index 0000000000..ab4932407e --- /dev/null +++ b/tests/mcp/test_tool_name_prefix.py @@ -0,0 +1,175 @@ +"""Tests for the per-server ``tool_name_prefix`` option. + +The prefix lets users disambiguate tools that share a name across MCP servers (for example, +``create_issue`` on both a GitHub and a Linear MCP server) without renaming the underlying +tools on the upstream servers. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from mcp.types import ListToolsResult, Tool as MCPTool + +from agents import Agent, FunctionTool +from agents.exceptions import UserError +from agents.mcp import MCPServerStdio, MCPUtil +from agents.run_context import RunContextWrapper +from agents.tool_context import ToolContext + +from .helpers import DummyStreamsContextManager, FakeMCPServer, tee + + +def _agent() -> Agent: + return Agent(name="test_agent", instructions="Test agent") + + +def _ctx() -> RunContextWrapper: + return RunContextWrapper(context=None) + + +@pytest.mark.asyncio +async def test_two_servers_with_different_prefixes_avoid_collision(): + """Two servers exposing the same tool name can co-exist when each picks a distinct prefix.""" + github = FakeMCPServer(server_name="github", tool_name_prefix="gh") + github.add_tool("create_issue", {}) + github.add_tool("list_issues", {}) + + linear = FakeMCPServer(server_name="linear", tool_name_prefix="ln") + linear.add_tool("create_issue", {}) + linear.add_tool("update_issue", {}) + + tools = await MCPUtil.get_all_function_tools([github, linear], False, _ctx(), _agent()) + + tool_names = [tool.name for tool in tools] + assert tool_names == [ + "gh_create_issue", + "gh_list_issues", + "ln_create_issue", + "ln_update_issue", + ] + + +@pytest.mark.asyncio +async def test_call_tool_strips_prefix_before_dispatching_upstream(): + """`call_tool` must hand the original (unprefixed) name to the upstream server.""" + github = FakeMCPServer(server_name="github", tool_name_prefix="gh") + github.add_tool("create_issue", {}) + + linear = FakeMCPServer(server_name="linear", tool_name_prefix="ln") + linear.add_tool("create_issue", {}) + + tools = await MCPUtil.get_all_function_tools([github, linear], False, _ctx(), _agent()) + + gh_tool, ln_tool = tools + assert isinstance(gh_tool, FunctionTool) + assert isinstance(ln_tool, FunctionTool) + assert gh_tool.name == "gh_create_issue" + assert ln_tool.name == "ln_create_issue" + + await gh_tool.on_invoke_tool( + ToolContext( + context=None, + tool_name=gh_tool.name, + tool_call_id="call_gh", + tool_arguments="{}", + ), + "{}", + ) + await ln_tool.on_invoke_tool( + ToolContext( + context=None, + tool_name=ln_tool.name, + tool_call_id="call_ln", + tool_arguments="{}", + ), + "{}", + ) + + # The fake servers record the upstream tool name that they were asked to invoke. Each + # server should only see calls to the original (unprefixed) name on its own side. + assert github.tool_calls == ["create_issue"] + assert linear.tool_calls == ["create_issue"] + + +@pytest.mark.asyncio +async def test_prefix_exceeding_length_limit_raises_user_error(): + """`list_tools()` rejects prefixes that would push a tool name past the 64-char limit.""" + long_prefix = "p" * 60 + server = FakeMCPServer(server_name="long", tool_name_prefix=long_prefix) + server.add_tool("create_issue", {}) # 60 + 1 + 12 = 73 chars after prefixing. + + with pytest.raises(UserError) as exc_info: + await server.list_tools(_ctx(), _agent()) + + assert "exceeds the 64-character limit" in str(exc_info.value) + assert "tool_name_prefix=" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_prefix_composes_with_tool_filter(): + """The static allow/block list must continue to match the upstream tool name.""" + server = FakeMCPServer( + server_name="github", + tool_filter={"allowed_tool_names": ["create_issue"]}, + tool_name_prefix="gh", + ) + server.add_tool("create_issue", {}) + server.add_tool("list_issues", {}) + + tools = await server.list_tools(_ctx(), _agent()) + assert [tool.name for tool in tools] == ["gh_create_issue"] + + +@pytest.mark.asyncio +async def test_prefix_does_not_mutate_cached_upstream_tools(): + """The internal cache must keep the original upstream names so retries hit the right tool.""" + server = FakeMCPServer(server_name="github", tool_name_prefix="gh") + server.add_tool("create_issue", {}) + + # The first listing applies the prefix to a copy; the source list keeps the original name. + tools = await server.list_tools(_ctx(), _agent()) + assert [tool.name for tool in tools] == ["gh_create_issue"] + assert [tool.name for tool in server.tools] == ["create_issue"] + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +@patch("mcp.client.session.ClientSession.call_tool", new_callable=AsyncMock) +async def test_stdio_server_applies_prefix_and_strips_on_call( + mock_call_tool: AsyncMock, + mock_list_tools: AsyncMock, + mock_initialize: AsyncMock, + mock_stdio_client, +): + """End-to-end check on the real MCPServerStdio: prefix in `list_tools`, strip in `call_tool`.""" + mock_list_tools.return_value = ListToolsResult( + tools=[MCPTool(name="create_issue", inputSchema={})] + ) + mock_call_tool.return_value = None + + server = MCPServerStdio( + params={"command": tee}, + cache_tools_list=True, + tool_name_prefix="gh", + ) + + async with server: + tools = await server.list_tools(_ctx(), _agent()) + assert [tool.name for tool in tools] == ["gh_create_issue"] + + # Caller passes the prefixed name; upstream sees the original. + await server.call_tool("gh_create_issue", {"title": "Bug"}) + assert mock_call_tool.call_args.args[0] == "create_issue" + + # If a caller still passes the upstream name directly, the strip is a no-op. + await server.call_tool("create_issue", {"title": "Bug"}) + assert mock_call_tool.call_args.args[0] == "create_issue" + + # Caching keeps upstream names so re-listing returns the same prefixed view. + tools = await server.list_tools(_ctx(), _agent()) + assert [tool.name for tool in tools] == ["gh_create_issue"] + assert mock_list_tools.call_count == 1