mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
* refactor(mcp): drop the experimental label from the MCP server and client paths Move litellm/proxy/_experimental/mcp_server to litellm/proxy/mcp_server and litellm/experimental_mcp_client to litellm/mcp_client, with the mirrored tests moved under tests/unit/proxy/mcp_server and tests/unit/mcp_client. Both old packages stay as shims that warn with DeprecationWarning, re-export the new package's public names and alias every submodule in sys.modules so deep imports keep resolving to the same objects. The OpenAPI docs gate now puts the merge base's enterprise package on PYTHONPATH so its base counts do not mix the checked-out enterprise code with the base litellm tree Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(mcp): keep tests/unit/proxy/_experimental as a sharded root for the shim tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): alias namespace subpackages and bind attributes in the deprecated MCP shims pkgutil.walk_packages skipped mcp_server/auth because it has no __init__.py, so old imports of litellm.proxy._experimental.mcp_server.auth.* failed, and sys.modules entries alone left litellm.experimental_mcp_client.tools unreachable as an attribute. Walk the package on disk and setattr each alias on its old parent. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
674 lines
25 KiB
Python
674 lines
25 KiB
Python
"""
|
|
Tests for the dynamic_mcp_route handler in proxy_server.py.
|
|
|
|
Covers the resolution order:
|
|
1. Registered MCP server alias → forwards to /mcp/{name}
|
|
2. Comma-separated list → forwards to /mcp/{segment}
|
|
3. Toolset name (cached) → sets toolset scope, forwards to /mcp
|
|
4. MCP access group tag (cached) → forwards to /mcp/{name} when the group
|
|
resolves to at least one server
|
|
5. Unknown name → 404
|
|
|
|
Patch targets are at the source modules because dynamic_mcp_route
|
|
uses lazy local imports inside the function body.
|
|
"""
|
|
|
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MCP_MANAGER = "litellm.proxy.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
|
_HANDLE_HTTP = (
|
|
"litellm.proxy.mcp_server.server.handle_streamable_http_mcp"
|
|
)
|
|
_STREAM_ASGI = "litellm.proxy.proxy_server._stream_mcp_asgi_response"
|
|
_PRISMA = "litellm.proxy.proxy_server.prisma_client"
|
|
_IS_ACCESS_GROUP = "litellm.proxy.proxy_server._is_mcp_access_group_cached"
|
|
_USER_API_KEY_CACHE = "litellm.proxy.proxy_server.user_api_key_cache"
|
|
_GET_ACCESS_GROUP_SERVERS = (
|
|
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp."
|
|
"MCPRequestHandler.get_mcp_servers_from_access_groups"
|
|
)
|
|
_FORWARD = "litellm.proxy.proxy_server._mcp_forward_as_path"
|
|
_RESOLVE_CSV = "litellm.proxy.proxy_server._resolve_mcp_csv_tokens"
|
|
|
|
|
|
def _make_request(path: str = "/test/mcp"):
|
|
"""Minimal fake Starlette Request."""
|
|
from starlette.requests import Request
|
|
|
|
scope = {
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": path,
|
|
"headers": [],
|
|
"query_string": b"",
|
|
"server": ("localhost", 4000),
|
|
"scheme": "http",
|
|
}
|
|
|
|
async def receive():
|
|
return {"type": "http.request", "body": b"{}"}
|
|
|
|
return Request(scope=scope, receive=receive)
|
|
|
|
|
|
def _fake_server(name: str = "my_server", server_id: str = "server-id-1"):
|
|
s = MagicMock()
|
|
s.name = name
|
|
s.server_id = server_id
|
|
return s
|
|
|
|
|
|
def _fake_toolset(name: str = "my_toolset", toolset_id: str = "ts-1"):
|
|
t = MagicMock()
|
|
t.toolset_id = toolset_id
|
|
t.name = name
|
|
return t
|
|
|
|
|
|
async def _ok_mcp_handle(scope, receive, send):
|
|
"""Stub MCP handler that returns HTTP 200."""
|
|
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
await send({"type": "http.response.body", "body": b"{}"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Registered MCP server alias
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_resolves_registered_server():
|
|
"""When the segment matches a known server alias the request is forwarded
|
|
to /mcp/{name} and the handler returns 200."""
|
|
from starlette.responses import Response
|
|
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
request = _make_request("/my_server/mcp")
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=_fake_server("my_server"))
|
|
|
|
fake_forward = AsyncMock(return_value=Response(content=b"{}", status_code=200))
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_FORWARD, new=fake_forward),
|
|
):
|
|
response = await dynamic_mcp_route("my_server", request)
|
|
|
|
assert response.status_code == 200
|
|
fake_forward.assert_awaited_once_with("my_server", request)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Comma-separated list (short-circuits before toolset DB call)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_comma_list_forwarded_when_tokens_resolve():
|
|
"""A comma-separated segment is forwarded after every token is resolved as
|
|
a known server / access group. Forwarding uses the deduped, validated
|
|
token list (so unknown / duplicate tokens cannot leak through). The
|
|
toolset DB lookup is bypassed entirely for comma names."""
|
|
from starlette.responses import Response
|
|
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
segment = "github_mcp,zapier"
|
|
request = _make_request(f"/{segment}/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=None)
|
|
|
|
fake_forward = AsyncMock(return_value=Response(content=b"{}", status_code=200))
|
|
fake_resolve = AsyncMock(return_value=["github_mcp", "zapier"])
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_RESOLVE_CSV, new=fake_resolve),
|
|
patch(_FORWARD, new=fake_forward),
|
|
):
|
|
response = await dynamic_mcp_route(segment, request)
|
|
|
|
assert response.status_code == 200
|
|
fake_forward.assert_awaited_once_with("github_mcp,zapier", request)
|
|
fake_mgr.get_toolset_by_name_cached.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_comma_list_returns_404_when_no_tokens_resolve():
|
|
"""A comma-separated segment with zero resolved tokens must 404 instead of
|
|
forwarding (downstream filter falls back to full allowed_mcp_servers when
|
|
no token matches, which would silently broaden scope)."""
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
segment = "ghost1,ghost2"
|
|
request = _make_request(f"/{segment}/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=None)
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_RESOLVE_CSV, new=AsyncMock(return_value=[])),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await dynamic_mcp_route(segment, request)
|
|
|
|
assert exc_info.value.status_code == 404
|
|
assert segment in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_comma_list_forwards_only_resolved_subset():
|
|
"""If only a subset of CSV tokens resolve, the request is forwarded with
|
|
just that subset (so unknown tokens cannot ride along into the downstream
|
|
server filter)."""
|
|
from starlette.responses import Response
|
|
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
segment = "github_mcp,ghost,zapier"
|
|
request = _make_request(f"/{segment}/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=None)
|
|
|
|
fake_forward = AsyncMock(return_value=Response(content=b"{}", status_code=200))
|
|
fake_resolve = AsyncMock(return_value=["github_mcp", "zapier"])
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_RESOLVE_CSV, new=fake_resolve),
|
|
patch(_FORWARD, new=fake_forward),
|
|
):
|
|
await dynamic_mcp_route(segment, request)
|
|
|
|
fake_forward.assert_awaited_once_with("github_mcp,zapier", request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_mcp_csv_tokens_dedupes_and_caps():
|
|
"""_resolve_mcp_csv_tokens dedupes tokens exact-match (so distinct casings
|
|
are preserved — downstream resolution may be case-sensitive), drops empty
|
|
fragments, and stops looking up after DEFAULT_MCP_NAMESPACE_CSV_MAX_TOKENS
|
|
unique tokens to bound DB / cache fan-out."""
|
|
from litellm.constants import DEFAULT_MCP_NAMESPACE_CSV_MAX_TOKENS
|
|
from litellm.proxy.proxy_server import _resolve_mcp_csv_tokens
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=_fake_server())
|
|
|
|
# "github_mcp" appears twice (once with surrounding whitespace) — must be
|
|
# collapsed to a single entry. "GITHUB_MCP" is a distinct exact token and
|
|
# is kept (downstream resolution may be case-sensitive).
|
|
csv = ",,github_mcp, github_mcp ,GITHUB_MCP," + ",".join(
|
|
f"srv_{i}" for i in range(DEFAULT_MCP_NAMESPACE_CSV_MAX_TOKENS + 5)
|
|
)
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_IS_ACCESS_GROUP, new=AsyncMock(return_value=False)),
|
|
):
|
|
resolved = await _resolve_mcp_csv_tokens(csv, client_ip=None)
|
|
|
|
assert len(resolved) == DEFAULT_MCP_NAMESPACE_CSV_MAX_TOKENS
|
|
assert resolved[0] == "github_mcp"
|
|
assert "GITHUB_MCP" in resolved
|
|
assert resolved.count("github_mcp") == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_mcp_csv_tokens_drops_unknown_and_resolves_access_groups():
|
|
"""Unknown tokens are dropped; access-group tokens are accepted via the
|
|
cached existence helper (no per-call uncached DB hit)."""
|
|
from litellm.proxy.proxy_server import _resolve_mcp_csv_tokens
|
|
|
|
fake_mgr = MagicMock()
|
|
# Only "registered_srv" is a known server alias.
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(
|
|
side_effect=lambda name, client_ip=None: (
|
|
_fake_server(name) if name == "registered_srv" else None
|
|
)
|
|
)
|
|
|
|
# "dev_group" is a real access group; "ghost" is not.
|
|
is_group = AsyncMock(side_effect=lambda name: name == "dev_group")
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_IS_ACCESS_GROUP, new=is_group),
|
|
):
|
|
resolved = await _resolve_mcp_csv_tokens(
|
|
"registered_srv,dev_group,ghost", client_ip=None
|
|
)
|
|
|
|
assert resolved == ["registered_srv", "dev_group"]
|
|
# Access-group lookup must NOT be called for "registered_srv" (already
|
|
# matched as a server alias) but MUST be called for "dev_group" and
|
|
# "ghost" (the only tokens that fall through to the access-group check).
|
|
assert {call.args[0] for call in is_group.await_args_list} == {
|
|
"dev_group",
|
|
"ghost",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Toolset name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_resolves_toolset():
|
|
"""When the segment is a toolset name the toolset context var is set
|
|
and the request is forwarded to /mcp (not /mcp/{name})."""
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
request = _make_request("/my_toolset/mcp")
|
|
fake_toolset = _fake_toolset("my_toolset", "ts-42")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=None)
|
|
fake_mgr.get_toolset_by_name_cached = AsyncMock(return_value=fake_toolset)
|
|
|
|
captured_toolset_id = None
|
|
captured_scope = {}
|
|
|
|
async def fake_stream(fn, scope, receive):
|
|
nonlocal captured_toolset_id
|
|
from litellm.proxy.mcp_server.server import (
|
|
mcp_active_toolset_id,
|
|
)
|
|
|
|
captured_toolset_id = mcp_active_toolset_id.get()
|
|
captured_scope.update(scope)
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_PRISMA, new=MagicMock()),
|
|
patch(_STREAM_ASGI, new=AsyncMock(side_effect=fake_stream)),
|
|
):
|
|
await dynamic_mcp_route("my_toolset", request)
|
|
|
|
assert captured_toolset_id == "ts-42"
|
|
assert captured_scope.get("path") == "/mcp"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. MCP access group tag (cached)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_resolves_access_group():
|
|
"""When the segment is an MCP access group the request is forwarded (not 404)."""
|
|
from starlette.responses import Response
|
|
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
request = _make_request("/dev_group/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=None)
|
|
fake_mgr.get_toolset_by_name_cached = AsyncMock(return_value=None)
|
|
|
|
fake_forward = AsyncMock(return_value=Response(content=b"{}", status_code=200))
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_PRISMA, new=MagicMock()),
|
|
patch(_IS_ACCESS_GROUP, new=AsyncMock(return_value=True)),
|
|
patch(_FORWARD, new=fake_forward),
|
|
):
|
|
response = await dynamic_mcp_route("dev_group", request)
|
|
|
|
assert response.status_code == 200
|
|
fake_forward.assert_awaited_once_with("dev_group", request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_access_group_called_with_correct_name():
|
|
"""The access group lookup receives exactly the segment from the URL."""
|
|
from starlette.responses import Response
|
|
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
request = _make_request("/qa_tools/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=None)
|
|
fake_mgr.get_toolset_by_name_cached = AsyncMock(return_value=None)
|
|
|
|
is_group = AsyncMock(return_value=True)
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_PRISMA, new=MagicMock()),
|
|
patch(_IS_ACCESS_GROUP, new=is_group),
|
|
patch(
|
|
_FORWARD,
|
|
new=AsyncMock(return_value=Response(content=b"{}", status_code=200)),
|
|
),
|
|
):
|
|
await dynamic_mcp_route("qa_tools", request)
|
|
|
|
is_group.assert_awaited_once_with("qa_tools")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_mcp_access_group_cached_caches_positive_result():
|
|
"""Known access groups are cached after resolving to one or more servers."""
|
|
from litellm.proxy.proxy_server import _is_mcp_access_group_cached
|
|
|
|
fake_cache = MagicMock()
|
|
fake_cache.async_get_cache = AsyncMock(return_value=None)
|
|
fake_cache.async_set_cache = AsyncMock()
|
|
get_access_group_servers = AsyncMock(return_value=["server-id"])
|
|
|
|
with (
|
|
patch(_USER_API_KEY_CACHE, new=fake_cache),
|
|
patch(_GET_ACCESS_GROUP_SERVERS, new=get_access_group_servers),
|
|
):
|
|
result = await _is_mcp_access_group_cached("dev_group")
|
|
|
|
assert result is True
|
|
get_access_group_servers.assert_awaited_once_with(["dev_group"])
|
|
fake_cache.async_set_cache.assert_awaited_once_with(
|
|
key="mcp_access_group_exists:dev_group",
|
|
value=True,
|
|
ttl=ANY,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_mcp_access_group_cached_caches_negative_result_briefly():
|
|
"""Empty access-group lookups are cached with a short TTL so unauthenticated
|
|
callers cannot force a fresh DB lookup per request for unknown names."""
|
|
from litellm.constants import DEFAULT_MCP_ACCESS_GROUP_NEGATIVE_CACHE_TTL
|
|
from litellm.proxy.proxy_server import _is_mcp_access_group_cached
|
|
|
|
fake_cache = MagicMock()
|
|
fake_cache.async_get_cache = AsyncMock(return_value=None)
|
|
fake_cache.async_set_cache = AsyncMock()
|
|
get_access_group_servers = AsyncMock(return_value=[])
|
|
|
|
with (
|
|
patch(_USER_API_KEY_CACHE, new=fake_cache),
|
|
patch(_GET_ACCESS_GROUP_SERVERS, new=get_access_group_servers),
|
|
):
|
|
result = await _is_mcp_access_group_cached("dev_group")
|
|
|
|
assert result is False
|
|
get_access_group_servers.assert_awaited_once_with(["dev_group"])
|
|
fake_cache.async_set_cache.assert_awaited_once_with(
|
|
key="mcp_access_group_exists:dev_group",
|
|
value=False,
|
|
ttl=DEFAULT_MCP_ACCESS_GROUP_NEGATIVE_CACHE_TTL,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_mcp_access_group_cached_returns_cached_negative_without_db():
|
|
"""A cached False entry short-circuits the DB lookup on subsequent calls."""
|
|
from litellm.proxy.proxy_server import _is_mcp_access_group_cached
|
|
|
|
fake_cache = MagicMock()
|
|
fake_cache.async_get_cache = AsyncMock(return_value=False)
|
|
fake_cache.async_set_cache = AsyncMock()
|
|
get_access_group_servers = AsyncMock(return_value=["server-id"])
|
|
|
|
with (
|
|
patch(_USER_API_KEY_CACHE, new=fake_cache),
|
|
patch(_GET_ACCESS_GROUP_SERVERS, new=get_access_group_servers),
|
|
):
|
|
result = await _is_mcp_access_group_cached("never_existed")
|
|
|
|
assert result is False
|
|
get_access_group_servers.assert_not_awaited()
|
|
fake_cache.async_set_cache.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Unknown name → 404
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_unknown_name_returns_404():
|
|
"""A segment that is not a server, toolset, or access group → 404."""
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
request = _make_request("/does_not_exist/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=None)
|
|
fake_mgr.get_toolset_by_name_cached = AsyncMock(return_value=None)
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_PRISMA, new=MagicMock()),
|
|
patch(_IS_ACCESS_GROUP, new=AsyncMock(return_value=False)),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await dynamic_mcp_route("does_not_exist", request)
|
|
|
|
assert exc_info.value.status_code == 404
|
|
assert "does_not_exist" in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_empty_access_group_returns_404():
|
|
"""An access group tag that resolves to zero servers still returns 404."""
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
request = _make_request("/empty_group/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(return_value=None)
|
|
fake_mgr.get_toolset_by_name_cached = AsyncMock(return_value=None)
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_PRISMA, new=MagicMock()),
|
|
patch(_IS_ACCESS_GROUP, new=AsyncMock(return_value=False)),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await dynamic_mcp_route("empty_group", request)
|
|
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Unexpected exception → 500 without leaking stack trace (CWE-209)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_mcp_route_unexpected_exception_returns_500_without_traceback():
|
|
"""CWE-209: an unexpected exception must return 500 with a generic message,
|
|
never leaking str(e) or a Python traceback to the caller."""
|
|
from litellm.proxy.proxy_server import dynamic_mcp_route
|
|
|
|
request = _make_request("/boom/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_mcp_server_by_name = MagicMock(
|
|
side_effect=RuntimeError("internal host: redis://10.0.0.1:6379")
|
|
)
|
|
|
|
with patch(_MCP_MANAGER, fake_mgr):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await dynamic_mcp_route("boom", request)
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert exc_info.value.detail == "Internal server error"
|
|
assert "10.0.0.1" not in str(exc_info.value.detail)
|
|
assert "traceback" not in str(exc_info.value.detail).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_toolset_mcp_route_unexpected_exception_returns_500_without_traceback():
|
|
"""CWE-209: toolset_mcp_route must return 500 with a generic message on
|
|
unexpected errors, never leaking exception text to the caller."""
|
|
from litellm.proxy.proxy_server import toolset_mcp_route
|
|
|
|
request = _make_request("/toolset/broken_toolset/mcp")
|
|
|
|
fake_mgr = MagicMock()
|
|
fake_mgr.get_toolset_by_name_cached = AsyncMock(
|
|
side_effect=RuntimeError("connection to db-host:5432 refused")
|
|
)
|
|
|
|
with (
|
|
patch(_MCP_MANAGER, fake_mgr),
|
|
patch(_PRISMA, new=MagicMock()),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await toolset_mcp_route("broken_toolset", request)
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert exc_info.value.detail == "Internal server error"
|
|
assert "db-host" not in str(exc_info.value.detail)
|
|
assert "traceback" not in str(exc_info.value.detail).lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Aggregate /mcp without a trailing slash (bare mount prefix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_IS_MCP_AVAILABLE = "litellm.proxy.mcp_server.utils.is_mcp_available"
|
|
|
|
|
|
def _test_client():
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.proxy_server import app
|
|
|
|
return TestClient(app, follow_redirects=False)
|
|
|
|
|
|
@pytest.mark.parametrize("method", ["GET", "POST", "DELETE"])
|
|
def test_aggregate_mcp_route_bare_path_is_served_not_redirected(method):
|
|
"""Bare /mcp must dispatch to the MCP handler with aggregate semantics,
|
|
never 307-redirect. Driven through the real app router so a lost route
|
|
registration (not just a broken handler body) fails this test."""
|
|
captured_scope: dict = {}
|
|
|
|
async def capturing_handle(scope, receive, send):
|
|
captured_scope.update(scope)
|
|
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
await send({"type": "http.response.body", "body": b"{}"})
|
|
|
|
with patch(_HANDLE_HTTP, new=capturing_handle):
|
|
response = _test_client().request(method, "/mcp")
|
|
|
|
assert response.status_code == 200
|
|
assert captured_scope.get("path") == "/mcp"
|
|
assert captured_scope.get("_original_path") == "/mcp"
|
|
|
|
|
|
def test_aggregate_mcp_route_requires_exact_path():
|
|
"""The bare-path route must match exactly /mcp; a sibling path like /mcpx
|
|
must not reach the MCP handler through it."""
|
|
calls = []
|
|
|
|
async def marking_handle(scope, receive, send):
|
|
calls.append(scope.get("path"))
|
|
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
await send({"type": "http.response.body", "body": b"{}"})
|
|
|
|
with patch(_HANDLE_HTTP, new=marking_handle):
|
|
response = _test_client().post("/mcpx")
|
|
|
|
assert calls == []
|
|
assert response.status_code != 200
|
|
|
|
|
|
def test_aggregate_mcp_route_returns_404_when_mcp_unavailable():
|
|
"""When the mcp package is unavailable the canonical /mcp/ sub-app is a
|
|
bare FastAPI that 404s, so the bare spelling must 404 identically instead
|
|
of erroring on the handler import."""
|
|
handler_calls = []
|
|
|
|
async def marking_handle(scope, receive, send):
|
|
handler_calls.append(scope.get("path"))
|
|
|
|
with (
|
|
patch(_IS_MCP_AVAILABLE, new=MagicMock(return_value=False)),
|
|
patch(_HANDLE_HTTP, new=marking_handle),
|
|
):
|
|
response = _test_client().post("/mcp")
|
|
|
|
assert response.status_code == 404
|
|
assert handler_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("change", ["create", "rename", "delete"])
|
|
@pytest.mark.parametrize("csv", [False, True])
|
|
async def test_dynamic_route_observes_committed_peer_catalog_changes(monkeypatch, change, csv):
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
|
|
from starlette.responses import Response
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LiteLLM_MCPServerTable
|
|
from litellm.proxy.mcp_server import mcp_server_manager
|
|
|
|
old_row = LiteLLM_MCPServerTable(
|
|
server_id="catalog-route", server_name="previous", alias="previous", transport="http",
|
|
url="https://previous.example/mcp", updated_at=datetime(2026, 1, 1),
|
|
)
|
|
new_row = old_row.model_copy(update={
|
|
"server_name": "current", "alias": "current", "url": "https://current.example/mcp",
|
|
"updated_at": datetime(2026, 1, 2),
|
|
})
|
|
|
|
async def find_rows(*, where):
|
|
if "mcp_access_groups" in where or change == "delete":
|
|
return []
|
|
return [new_row]
|
|
|
|
read_rows = AsyncMock(side_effect=find_rows)
|
|
prisma = SimpleNamespace(db=SimpleNamespace(
|
|
litellm_mcpservertable=SimpleNamespace(find_many=read_rows),
|
|
litellm_mcptoolsettable=SimpleNamespace(find_first=AsyncMock(return_value=None)),
|
|
litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None)),
|
|
))
|
|
prisma.writer_db = prisma.db
|
|
manager = mcp_server_manager.MCPServerManager()
|
|
if change != "create":
|
|
manager.registry = {old_row.server_id: await manager.build_mcp_server_from_table(old_row)}
|
|
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
|
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
name = "previous" if change == "delete" else "current"
|
|
segment = f"{name},missing" if csv else name
|
|
request = _make_request(f"/{segment}/mcp")
|
|
|
|
async def forwarded(path_segment, request):
|
|
if change != "delete":
|
|
assert manager.get_mcp_server_by_name(path_segment).url == new_row.url
|
|
return Response(content=b"forwarded", status_code=200)
|
|
|
|
with patch(_FORWARD, new=AsyncMock(side_effect=forwarded)) as forward:
|
|
if change == "delete":
|
|
with pytest.raises(HTTPException) as exc:
|
|
await proxy_server.dynamic_mcp_route(segment, request)
|
|
assert exc.value.status_code == 404
|
|
forward.assert_not_awaited()
|
|
else:
|
|
response = await proxy_server.dynamic_mcp_route(segment, request)
|
|
assert response.status_code == 200
|
|
forward.assert_awaited_once_with(name, request)
|
|
assert sum("OR" in call.kwargs["where"] for call in read_rows.await_args_list) == 1
|