Skip to content
Merged
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
93 changes: 52 additions & 41 deletions litellm/proxy/_experimental/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ def _jsonrpc_text_has_top_level_method(text: str) -> bool:
global_mcp_tool_registry,
)
from litellm.proxy._experimental.mcp_server.utils import (
is_tool_name_prefixed,
normalize_server_name,
split_server_prefix_from_name,
)

Expand Down Expand Up @@ -2483,47 +2485,56 @@ async def execute_mcp_tool( # noqa: PLR0915
None,
)

# Resolve the actual MCP server up-front so the permission check uses
# the canonical server.name even when the tool name is prefixed with a
# short ID (LITELLM_USE_SHORT_MCP_TOOL_PREFIX) that doesn't match the
# server's display name directly.
mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
if mcp_server is None and requested_server is not None:
# REST callers may pass the raw tool name (no prefix) plus a
# ``requested_server_id``. The mapping might only contain the
# prefixed form, so retry the lookup with every known prefix of
# the requested server before treating the tool as unresolved —
# otherwise the tool_server_mismatch guard below is silently
# bypassed.
for known_prefix in iter_known_server_prefixes(requested_server):
candidate = global_mcp_server_manager._get_mcp_server_from_tool_name(
add_server_prefix_to_name(name, known_prefix)
)
if candidate is not None:
mcp_server = candidate
break
if mcp_server is not None:
server_name = mcp_server.name

# REST /mcp-rest/tools/call passes server_id — tool must belong to that server
if requested_server is not None:
if (
mcp_server is not None
and mcp_server.server_id != requested_server.server_id
):
raise HTTPException(
status_code=403,
detail={
"error": "tool_server_mismatch",
"message": (
f"Tool '{name}' belongs to MCP server '{mcp_server.name}' "
f"but request specified server_id for '{requested_server.name}'."
),
},
)
if mcp_server is None:
mcp_server = requested_server
server_name = requested_server.name
known_server_prefixes: Set[str] = set()
for allowed_server in allowed_mcp_servers:
for known_prefix in iter_known_server_prefixes(allowed_server):
known_server_prefixes.add(normalize_server_name(known_prefix))

name_is_prefixed = is_tool_name_prefixed(
name, known_server_prefixes=known_server_prefixes
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

if requested_server is not None and not name_is_prefixed:
# REST callers may pass server_id with the upstream tool name (no
# LiteLLM prefix). Multiple MCP entries can share the same URL and
# tool name; server_id is authoritative for routing and auth.
mcp_server = requested_server
server_name = requested_server.name
else:
# Resolve from tool name (MCP JSON-RPC or prefixed REST tool names).
mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
if mcp_server is None and requested_server is not None:
for known_prefix in iter_known_server_prefixes(requested_server):
candidate = (
global_mcp_server_manager._get_mcp_server_from_tool_name(
add_server_prefix_to_name(name, known_prefix)
)
)
if candidate is not None:
mcp_server = candidate
break
if mcp_server is not None:
server_name = mcp_server.name

if requested_server is not None:
if (
mcp_server is not None
and mcp_server.server_id != requested_server.server_id
):
raise HTTPException(
status_code=403,
detail={
"error": "tool_server_mismatch",
"message": (
f"Tool '{name}' belongs to MCP server "
f"'{mcp_server.name}' but request specified "
f"server_id for '{requested_server.name}'."
),
},
)
if mcp_server is None:
mcp_server = requested_server
server_name = requested_server.name

# Only enforce server-level permissions when we can resolve a server
if server_name:
Expand Down
131 changes: 131 additions & 0 deletions tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5489,3 +5489,134 @@ async def test_create_mcp_client_sampling_enabled():

client = await manager._create_mcp_client(server=server)
assert client._sampling_callback is not None


@pytest.mark.asyncio
async def test_execute_mcp_tool_rest_server_id_authoritative_for_unprefixed_tool():
"""REST server_id + unprefixed tool name must not use global tool-name mapping."""
from mcp.types import TextContent

from litellm.proxy._experimental.mcp_server import server as mcp_module

api_key_server = MCPServer(
server_id="api-key-server-id",
name="echo_api_key",
server_name="echo_api_key",
url="http://127.0.0.1:5115/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.api_key,
authentication_token="abc123",
)
oauth_server = MCPServer(
server_id="oauth-server-id",
name="echo_oauth_m2m",
server_name="echo_oauth_m2m",
url="http://127.0.0.1:5115/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
token_url="http://127.0.0.1:8080/token",
client_id="client",
client_secret="secret",
)

mcp_module.global_mcp_server_manager.tool_name_to_mcp_server_name_mapping[
"echo"
] = oauth_server.name

captured: dict = {}

async def fake_handle_managed_mcp_tool(**kwargs):
captured.update(kwargs)
return mcp_module.CallToolResult(
content=[TextContent(type="text", text="ok")],
isError=False,
)

with (
patch.object(
mcp_module.global_mcp_server_manager,
"_get_mcp_server_from_tool_name",
return_value=oauth_server,
),
patch.object(
mcp_module,
"_handle_managed_mcp_tool",
new=fake_handle_managed_mcp_tool,
),
patch.object(
mcp_module.MCPRequestHandler,
"is_tool_allowed",
return_value=True,
),
patch.object(
mcp_module.global_mcp_tool_registry,
"get_tool",
return_value=None,
),
):
await mcp_module.execute_mcp_tool(
name="echo",
arguments={"message": "hello"},
allowed_mcp_servers=[api_key_server, oauth_server],
start_time=datetime.now(),
requested_server_id=api_key_server.server_id,
)

assert captured["server_name"] == "echo_api_key"
assert captured["name"] == "echo"


@pytest.mark.asyncio
async def test_execute_mcp_tool_rest_prefixed_tool_still_validates_server_id():
"""Prefixed REST tool names must still match the requested server_id."""
from litellm.proxy._experimental.mcp_server import server as mcp_module

api_key_server = MCPServer(
server_id="api-key-server-id",
name="echo_api_key",
server_name="echo_api_key",
url="http://127.0.0.1:5115/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.api_key,
authentication_token="abc123",
)
oauth_server = MCPServer(
server_id="oauth-server-id",
name="echo_oauth_m2m",
server_name="echo_oauth_m2m",
url="http://127.0.0.1:5115/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
token_url="http://127.0.0.1:8080/token",
client_id="client",
client_secret="secret",
)

with (
patch.object(
mcp_module.global_mcp_server_manager,
"_get_mcp_server_from_tool_name",
return_value=oauth_server,
),
patch.object(
mcp_module.MCPRequestHandler,
"is_tool_allowed",
return_value=True,
),
patch.object(
mcp_module.global_mcp_tool_registry,
"get_tool",
return_value=None,
),
pytest.raises(HTTPException) as exc_info,
):
await mcp_module.execute_mcp_tool(
name="echo_oauth_m2m-echo",
arguments={"message": "hello"},
allowed_mcp_servers=[api_key_server, oauth_server],
start_time=datetime.now(),
requested_server_id=api_key_server.server_id,
)

assert exc_info.value.status_code == 403
assert exc_info.value.detail["error"] == "tool_server_mismatch"
Loading