From 9ce8a2d956466972665fd2579df8e69664354307 Mon Sep 17 00:00:00 2001 From: jibanez-staticduo Date: Wed, 13 May 2026 17:01:01 +0200 Subject: [PATCH] fix(mcp): recheck cached LazyMCP catalog visibility Filter cached LazyMCP catalogs through the current allowed-server set before returning list/status metadata, and mark mcp_client_ip as an optional SecretFields key. --- .../proxy/_experimental/mcp_server/server.py | 22 ++++- litellm/types/proxy/litellm_pre_call_utils.py | 4 +- .../mcp_server/test_mcp_server.py | 93 +++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 08046ef6f87..f2598de9bf7 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -2080,7 +2080,27 @@ if MCP_AVAILABLE: cache_key = f"lazymcp:catalog:{scope_hash}" cached = await _lazymcp_cache_get(cache_key) if isinstance(cached, dict): - return cached + allowed_servers = await _get_lazymcp_allowed_servers( + user_api_key_auth=user_api_key_auth, + mcp_servers=mcp_servers, + client_ip=client_ip, + ) + allowed_names = { + _get_lazymcp_server_label(server).lower() for server in allowed_servers + } + filtered_servers = [ + server + for server in cached.get("servers", []) + if str(server.get("name", "")).lower() in allowed_names + ] + return { + **cached, + "servers": filtered_servers, + "server_count": len(filtered_servers), + "tool_count": sum( + server.get("tool_count", 0) for server in filtered_servers + ), + } allowed_servers = await _get_lazymcp_allowed_servers( user_api_key_auth=user_api_key_auth, diff --git a/litellm/types/proxy/litellm_pre_call_utils.py b/litellm/types/proxy/litellm_pre_call_utils.py index 8f25d593cb3..30e024f48ee 100644 --- a/litellm/types/proxy/litellm_pre_call_utils.py +++ b/litellm/types/proxy/litellm_pre_call_utils.py @@ -1,6 +1,6 @@ from typing import Optional -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict class RedactedDict(dict): @@ -24,4 +24,4 @@ class SecretFields(TypedDict): """ raw_headers: dict - mcp_client_ip: Optional[str] + mcp_client_ip: NotRequired[Optional[str]] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 8412ff671ff..4e210e698eb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -427,6 +427,99 @@ def test_lazymcp_cache_helpers_tolerate_cache_errors(monkeypatch): fake_cache.async_set_cache.assert_awaited_once() +@pytest.mark.asyncio +async def test_lazymcp_cached_catalog_rechecks_current_visibility(monkeypatch): + try: + from litellm.proxy._experimental.mcp_server import server as mcp_server_module + except ImportError: + pytest.skip("MCP server not available") + + cached_catalog = { + "description": "cached catalog", + "server_count": 2, + "tool_count": 3, + "servers": [ + {"name": "visible", "description": "ok", "tool_count": 1, "tools": []}, + { + "name": "revoked", + "description": "must not leak", + "tool_count": 2, + "tools": [], + }, + ], + } + allowed_server = types.SimpleNamespace( + server_id="visible-id", alias="visible", server_name=None, name="visible" + ) + + monkeypatch.setattr( + mcp_server_module, + "_lazymcp_cache_get", + AsyncMock(return_value=cached_catalog), + ) + monkeypatch.setattr( + mcp_server_module, + "_get_lazymcp_allowed_servers", + AsyncMock(return_value=[allowed_server]), + ) + + catalog = await mcp_server_module._get_lazymcp_catalog( + user_api_key_auth=None, + mcp_auth_header=None, + mcp_servers=None, + mcp_server_auth_headers=None, + oauth2_headers=None, + raw_headers=None, + client_ip="10.0.0.1", + ) + + assert catalog["server_count"] == 1 + assert catalog["tool_count"] == 1 + assert [server["name"] for server in catalog["servers"]] == ["visible"] + + +@pytest.mark.asyncio +async def test_lazymcp_cached_catalog_hides_all_revoked_servers(monkeypatch): + try: + from litellm.proxy._experimental.mcp_server import server as mcp_server_module + except ImportError: + pytest.skip("MCP server not available") + + monkeypatch.setattr( + mcp_server_module, + "_lazymcp_cache_get", + AsyncMock( + return_value={ + "description": "cached catalog", + "server_count": 1, + "tool_count": 1, + "servers": [ + {"name": "revoked", "description": "must not leak", "tools": []} + ], + } + ), + ) + monkeypatch.setattr( + mcp_server_module, + "_get_lazymcp_allowed_servers", + AsyncMock(return_value=[]), + ) + + catalog = await mcp_server_module._get_lazymcp_catalog( + user_api_key_auth=None, + mcp_auth_header=None, + mcp_servers=None, + mcp_server_auth_headers=None, + oauth2_headers=None, + raw_headers=None, + client_ip="10.0.0.1", + ) + + assert catalog["server_count"] == 0 + assert catalog["tool_count"] == 0 + assert catalog["servers"] == [] + + def test_invalidating_toolset_cache_tolerates_lazymcp_invalidation_error(): try: from litellm.proxy._experimental.mcp_server.mcp_server_manager import (