This commit is contained in:
Artur Sidorenko 2026-11-07 05:54:22 +00:00 • committed by GitHub
commit 07e364cdac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 64 additions and 3 deletions

View file

@ -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.",

View file

@ -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.

View file

@ -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