Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
25 changes: 25 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
108 changes: 100 additions & 8 deletions src/agents/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ class _UnsetType:
)


MCP_TOOL_NAME_MAX_LENGTH = 64


class MCPServer(abc.ABC):
"""Base class for Model Context Protocol servers."""

Expand All @@ -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:
Expand All @@ -248,13 +253,56 @@ 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(
require_approval=require_approval
)
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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion tests/mcp/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -109,14 +112,19 @@ 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,
tool_name: str,
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)
Expand Down
Loading