litellm/tests/unit/proxy/test_dynamic_mcp_route.py
Mateo Wang 0a1dbc0fb4
refactor(mcp): drop the experimental label from the MCP server and client paths (#48513)
* 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>
2026-10-27 17:47:50 +00:00

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