fix(mcp): do not prefix tool names when listing via scoped /mcp/{server_name} endpoint for stdio servers (#22670)

When an MCP list_tools request arrives via a URL-scoped path such as
/mcp/{server_name}, the client has already disambiguated the server by
the URL. Prefixing the returned tool names with the server name in that
case causes consumers (e.g. the GitHub on-prem stdio MCP server in
#22670) to see names like github_onprem-get_repo instead of get_repo.

The aggregated /mcp endpoint still prefixes tool names so the merged
list stays globally unique.

Implementation:
- Add a transport-agnostic ContextVar (_mcp_request_scoped_to_single_server)
  set in extract_mcp_auth_context when a single server is parsed from the
  URL path.
- Honour the flag in all four list_* server-fetch call sites (tools,
  prompts, resources, resource templates) so behaviour is consistent
  across stdio, SSE and HTTP-streamable transports.

Fixes #22670
This commit is contained in:
mateo-berri 2026-04-23 17:40:11 -07:00
parent 87e120d958
commit eb060b1105
3 changed files with 128 additions and 4 deletions

View file

@ -19,3 +19,16 @@ _mcp_active_toolset_id: ContextVar[Optional[str]] = ContextVar(
_mcp_gateway_initialize_instructions: ContextVar[Optional[str]] = ContextVar(
"_mcp_gateway_initialize_instructions", default=None
)
# Set when an MCP request arrives via a path-scoped URL like /mcp/{server_name}
# (or /{server_name}/mcp). In that case the client has already disambiguated the
# server by URL, so tool names from list_tools / list_prompts / list_resources /
# list_resource_templates SHOULD NOT be prefixed with the server name. When the
# request comes in on the aggregated /mcp endpoint (no server in the path) this
# flag stays False and the aggregated list keeps its server-name prefixes so
# tool names remain globally unique.
# This is intentionally transport-agnostic: stdio, SSE and HTTP-streamable servers
# all honour the same flag, so behaviour stays consistent across transports.
_mcp_request_scoped_to_single_server: ContextVar[bool] = ContextVar(
"_mcp_request_scoped_to_single_server", default=False
)

View file

@ -41,6 +41,7 @@ from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
from litellm.proxy._experimental.mcp_server.mcp_context import (
_mcp_active_toolset_id,
_mcp_gateway_initialize_instructions,
_mcp_request_scoped_to_single_server,
)
from litellm.proxy._experimental.mcp_server.mcp_debug import MCPDebug
from litellm.proxy._experimental.mcp_server.utils import (
@ -1319,11 +1320,19 @@ if MCP_AVAILABLE:
)
try:
# For scoped /mcp/{server_name} requests the client has already
# disambiguated by URL, so tool names should NOT be prefixed
# (see issue #22670). Otherwise keep the default behaviour of
# prefixing tool names with the server name for the aggregated
# /mcp endpoint.
add_prefix_for_server = (
not _mcp_request_scoped_to_single_server.get()
)
tools = await global_mcp_server_manager._get_tools_from_server(
server=server,
mcp_auth_header=server_auth_header,
extra_headers=extra_headers,
add_prefix=True, # Always add server prefix
add_prefix=add_prefix_for_server,
raw_headers=raw_headers,
)
filtered_tools = filter_tools_by_allowed_tools(tools, server)
@ -1460,11 +1469,14 @@ if MCP_AVAILABLE:
)
try:
# Honour scoped /mcp/{server_name} requests — no prefix when
# the URL already disambiguates the server (see issue #22670).
add_prefix_for_server = not _mcp_request_scoped_to_single_server.get()
prompts = await global_mcp_server_manager.get_prompts_from_server(
server=server,
mcp_auth_header=server_auth_header,
extra_headers=extra_headers,
add_prefix=True, # Always add server prefix
add_prefix=add_prefix_for_server,
raw_headers=raw_headers,
)
@ -1517,11 +1529,14 @@ if MCP_AVAILABLE:
)
try:
# Honour scoped /mcp/{server_name} requests — no prefix when
# the URL already disambiguates the server (see issue #22670).
add_prefix_for_server = not _mcp_request_scoped_to_single_server.get()
resources = await global_mcp_server_manager.get_resources_from_server(
server=server,
mcp_auth_header=server_auth_header,
extra_headers=extra_headers,
add_prefix=True, # Always add server prefix
add_prefix=add_prefix_for_server,
raw_headers=raw_headers,
)
all_resources.extend(resources)
@ -1572,12 +1587,15 @@ if MCP_AVAILABLE:
)
try:
# Honour scoped /mcp/{server_name} requests — no prefix when
# the URL already disambiguates the server (see issue #22670).
add_prefix_for_server = not _mcp_request_scoped_to_single_server.get()
resource_templates = (
await global_mcp_server_manager.get_resource_templates_from_server(
server=server,
mcp_auth_header=server_auth_header,
extra_headers=extra_headers,
add_prefix=True, # Always add server prefix
add_prefix=add_prefix_for_server,
raw_headers=raw_headers,
)
)
@ -2478,6 +2496,14 @@ if MCP_AVAILABLE:
raw_headers,
) = await MCPRequestHandler.process_mcp_request(scope)
mcp_servers = mcp_servers_from_path
# When the client disambiguates the MCP server via URL path
# (e.g. /mcp/github_onprem), downstream list_* handlers should return
# tool / prompt / resource names WITHOUT the server-name prefix, since
# there is no ambiguity to resolve. Setting this ContextVar is
# transport-agnostic so stdio, SSE and HTTP-streamable servers behave
# consistently for scoped requests. See issue #22670.
if len(mcp_servers_from_path) == 1:
_mcp_request_scoped_to_single_server.set(True)
else:
(
user_api_key_auth,

View file

@ -1297,6 +1297,91 @@ async def test_list_tools_multiple_servers_prefixed_names():
assert names == ["jira-toolA", "zapier-toolA"]
@pytest.mark.asyncio
async def test_list_tools_scoped_via_url_path_does_not_prefix_stdio_server():
"""Regression test for https://github.com/BerriAI/litellm/issues/22670.
When an MCP list_tools request arrives via the scoped URL path
``/mcp/{server_name}`` (rather than the aggregated ``/mcp`` endpoint),
the client has already disambiguated the server by the URL, so tool
names MUST NOT be prefixed with the server name. This must hold for
every transport — stdio included — so that the stdio-backed GitHub
on-prem MCP server in issue #22670 does not return names like
``github_onprem-get_repo`` when scoped.
"""
try:
from litellm.proxy._experimental.mcp_server.mcp_context import (
_mcp_request_scoped_to_single_server,
)
from litellm.proxy._experimental.mcp_server.server import (
_get_tools_from_mcp_servers,
set_auth_context,
)
except ImportError:
pytest.skip("MCP server not available")
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user")
set_auth_context(user_api_key_auth)
# stdio-transport MCP server matching the reporter's config in #22670.
server = MagicMock()
server.server_id = "github_onprem_id"
server.name = "github_onprem"
server.alias = "github_onprem"
server.server_name = "github_onprem"
server.allowed_tools = None
server.disallowed_tools = None
server.auth_type = None
server.extra_headers = None
server.transport = MCPTransport.stdio
received_add_prefix: dict = {}
async def mock_get_tools_from_server(
server,
mcp_auth_header=None,
extra_headers=None,
add_prefix=True,
raw_headers=None,
):
received_add_prefix["value"] = add_prefix
tool = MagicMock()
tool.name = f"{server.alias}-get_repo" if add_prefix else "get_repo"
tool.description = "desc"
tool.inputSchema = {}
return [tool]
mock_manager = MagicMock()
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["github_onprem_id"])
mock_manager.get_mcp_server_by_id = MagicMock(return_value=server)
mock_manager.filter_server_ids_by_ip_with_info = lambda ids, _ip: (ids, 0)
mock_manager._get_tools_from_server = mock_get_tools_from_server
# Simulate the scoped /mcp/{server_name} request by setting the
# ContextVar that `extract_mcp_auth_context` sets on the real request
# path. Setting it here lets us unit-test the decision logic without
# bringing up a Starlette app.
token = _mcp_request_scoped_to_single_server.set(True)
try:
with patch(
"litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager",
mock_manager,
):
tools = await _get_tools_from_mcp_servers(
user_api_key_auth=user_api_key_auth,
mcp_auth_header=None,
mcp_servers=["github_onprem"],
mcp_server_auth_headers=None,
)
finally:
_mcp_request_scoped_to_single_server.reset(token)
# Core bug: on the scoped URL, tool names must be returned unprefixed.
assert received_add_prefix.get("value") is False
assert len(tools) == 1
assert tools[0].name == "get_repo"
@pytest.mark.asyncio
async def test_mcp_manager_allows_public_servers_without_permissions():
try: