""" 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 → short-circuits before any DB call; forwarded 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._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" _HANDLE_HTTP = ( "litellm.proxy._experimental.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._experimental.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._experimental.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._experimental.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 == []