diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e53b5d4ad52..2304e1c59b9 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3032,6 +3032,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="Request header whose value names the calling MCP client application (for example 'x-mcp-client') for callers that did not authenticate with a JWT, used only while mcp_allowed_clients is set. The client picks this value itself, so it is a policy control rather than a security boundary; prefer litellm_jwtauth.mcp_client_id_jwt_field where callers use JWTs.", ) + mcp_catalog_fresh_policy: bool | None = Field( + None, + description="When false, MCP tool listings read the caller's user, team and grant policy through the management-object cache instead of the database, so a revoked grant leaves the listing within user_api_key_cache_ttl, the same bound as every other authenticated request. Default true: every listing re-reads the policy from the database, which serializes parallel listings on a single worker.", + ) mcp_trusted_proxy_ranges: list[str] | None = Field( None, description="CIDR ranges of trusted reverse proxies. When set, X-Forwarded-For and X-Forwarded-* origin headers are only trusted from these IPs.", diff --git a/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py index be24b1630b0..edba93368bb 100644 --- a/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py @@ -1581,17 +1581,18 @@ class MCPRequestHandler: if auth is None: return None - refreshed: Final = auth.model_copy(update={"requires_fresh_policy": True}) + fresh: Final = MCPRequestHandler._catalog_requires_fresh_policy() + refreshed: Final = auth.model_copy(update={"requires_fresh_policy": fresh or auth.requires_fresh_policy}) if (auth.mcp_admitted_user_subject or auth.is_session_token) and auth.user_id: # A session credential (``lite login`` or a redeemed ID-JAG) has no key row, its authority is # sealed in the token: refresh the live user policy and keep the token's team, routes and models. - current = await MCPRequestHandler.reload_admitted_user(auth.user_id, requires_fresh_policy=True) + current = await MCPRequestHandler.reload_admitted_user(auth.user_id, requires_fresh_policy=fresh) refreshed.object_permission = current.object_permission refreshed.object_permission_id = current.object_permission_id refreshed.user_role = current.user_role refreshed.org_id = current.org_id elif auth.via_virtual_key and auth.api_key and auth.api_key != LITELLM_PROXY_MASTER_KEY_ALIAS: - current = await MCPRequestHandler._reload_admitted_key(auth.api_key, check_db_only=True) + current = await MCPRequestHandler._reload_admitted_key(auth.api_key, check_db_only=fresh) refreshed.object_permission = current.object_permission refreshed.object_permission_id = current.object_permission_id refreshed.team_id = current.team_id @@ -1607,6 +1608,19 @@ class MCPRequestHandler: return await apply_toolset_scope(refreshed, refreshed.mcp_toolset_id) return refreshed + @staticmethod + def _catalog_requires_fresh_policy() -> bool: + """Whether a catalog listing re-reads the caller's policy from the database. + + On by default, so a revoked grant leaves the listing at once. Every listing then reads the + user, each team and each grant past the cache, which on a single worker serializes parallel + listings (a client opening several MCP servers at once). ``general_settings.mcp_catalog_fresh_policy: + false`` reads them through the management-object cache instead, so a revoked grant leaves the + listing within ``user_api_key_cache_ttl``, the same bound as every other authenticated request.""" + from litellm.proxy.proxy_server import general_settings + + return general_settings.get("mcp_catalog_fresh_policy", True) is not False + @staticmethod async def _reload_admitted_key(key_hash: str, *, check_db_only: bool = False) -> UserAPIKeyAuth: """Reload the live key record an admitted envelope references and re-check live policy. diff --git a/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py index 7e74e62894f..d349ce14adc 100644 --- a/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -13252,3 +13252,46 @@ async def test_admin_dashboard_session_keeps_the_whole_mcp_registry( assert set(await global_mcp_server_manager.get_allowed_mcp_servers(dashboard_session)) == set( global_mcp_server_manager.get_registry() ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("setting", "fresh"), [(None, True), (True, True), (False, False)]) +async def test_catalog_refresh_reads_the_user_policy_fresh_unless_the_proxy_opts_out(monkeypatch, setting, fresh): + from litellm.proxy import proxy_server + + settings: Final = {} if setting is None else {"mcp_catalog_fresh_policy": setting} + monkeypatch.setattr(proxy_server, "general_settings", settings) + reload_user = AsyncMock(return_value=UserAPIKeyAuth(user_id="catalog-user", user_role="internal_user")) + monkeypatch.setattr(MCPRequestHandler, "reload_admitted_user", reload_user) + caller = UserAPIKeyAuth(user_id="catalog-user") + caller.mcp_admitted_user_subject = True + refreshed = await MCPRequestHandler.refresh_catalog_authority(caller) + reload_user.assert_awaited_once_with("catalog-user", requires_fresh_policy=fresh) + assert refreshed.requires_fresh_policy is fresh + + +@pytest.mark.asyncio +async def test_catalog_refresh_opt_out_reads_the_virtual_key_through_the_cache(monkeypatch): + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", {"mcp_catalog_fresh_policy": False}) + reload_key = AsyncMock(return_value=UserAPIKeyAuth(team_id="current-team")) + monkeypatch.setattr(MCPRequestHandler, "_reload_admitted_key", reload_key) + caller = UserAPIKeyAuth(api_key="owned-key-hash") + caller.via_virtual_key = True + refreshed = await MCPRequestHandler.refresh_catalog_authority(caller) + reload_key.assert_awaited_once_with("owned-key-hash", check_db_only=False) + assert refreshed.team_id == "current-team" + assert refreshed.requires_fresh_policy is False + + +@pytest.mark.asyncio +async def test_catalog_refresh_opt_out_keeps_a_caller_that_already_requires_fresh_policy(monkeypatch): + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", {"mcp_catalog_fresh_policy": False}) + caller = UserAPIKeyAuth(api_key="custom-subject") + caller.authenticated_by_custom_auth = True + caller.requires_fresh_policy = True + refreshed = await MCPRequestHandler.refresh_catalog_authority(caller) + assert refreshed.requires_fresh_policy is True