Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py: 34%
1341 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1import re
2from collections.abc import Mapping, Sequence
3from dataclasses import dataclass
4from datetime import datetime, timezone
5from types import MappingProxyType
6from typing import TYPE_CHECKING, Final, Literal, cast
8from fastapi import HTTPException
9from starlette.datastructures import Headers
10from starlette.requests import Request
11from starlette.types import Scope
12from typing_extensions import assert_never
14import litellm
15from litellm._logging import verbose_logger
16from litellm.constants import MCP_ALL_TOOLS_WILDCARD
17from litellm.proxy._experimental.mcp_server.oauth_utils import (
18 get_passthrough_resource_metadata_url,
19 get_passthrough_www_authenticate,
20 get_request_base_url,
21 well_known_root_suffix,
22)
23from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
24 BridgeEnvelopeAdmitted,
25 BridgeEnvelopeInvalid,
26 NotBridgeEnvelope,
27 envelope_keys_from_master_key,
28 is_bridge_envelope_shaped,
29 resolve_bridge_envelope,
30)
31from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
32 EnvelopeIdentity,
33)
34from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
35 is_session_bearer_shaped,
36)
37from litellm.proxy._types import (
38 UI_TEAM_ID,
39 LiteLLM_ObjectPermissionTable,
40 LiteLLM_TeamTable,
41 ProxyException,
42 SpecialHeaders,
43 SpecialMCPServerName,
44 SpecialMCPServerNames,
45 UserAPIKeyAuth,
46 hash_token,
47 user_api_key_has_admin_view,
48)
49from litellm.proxy.agent_endpoints.auth.agent_access_groups import (
50 CeilingResolver,
51 resolve_agent_access_group_ceiling,
52)
53from litellm.proxy.agent_endpoints.auth.agent_caller import agent_caller_auth
54from litellm.proxy.auth.ip_address_utils import IPAddressUtils
55from litellm.proxy.auth.user_api_key_auth import (
56 _get_bearer_token_or_received_api_key, # pyright: ignore[reportPrivateUsage] # shared x-litellm-api-key parser lives with user_api_key_auth
57 _run_centralized_common_checks,
58 user_api_key_auth,
59)
60from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
61from litellm.proxy.common_utils.user_api_key_cache import (
62 USER_NO_MCP_PERMISSION_SENTINEL,
63 get_management_object_ttl,
64 user_object_permission_id_cache_key,
65)
66from litellm.repositories.table_repositories import (
67 AgentsRepository,
68 MCPServerRepository,
69)
70from litellm.repositories.user_repository import UserRepository
71from litellm.types.mcp_server.mcp_server_manager import MCPServer
73if TYPE_CHECKING: 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true
74 from litellm.proxy.utils import PrismaClient
77_EMPTY_TOOLSET_GRANTS: Final[Mapping[str, Sequence[str]]] = MappingProxyType({})
80def _as_list(values: Sequence[str] | None) -> list[str] | None: # mutable-ok: resolver returns a list
81 """Widen a read-only allowlist back to the mutable list the resolver's own contract returns,
82 preserving the ``None`` that means "no restriction"."""
83 return None if values is None else list(values)
86class UnloadableEntitlementError(Exception):
87 """A principal's row NAMES an ``object_permission_id`` whose contents could not be read.
89 Raised only where there is POSITIVE evidence an entitlement exists, so every caller must DENY
90 rather than fall back to "this level places no restriction": a ceiling we know exists but cannot
91 read would otherwise silently widen the caller for as long as the fault lasts.
93 Deliberately distinct from a lookup that fails before the principal's entitlement is known at
94 all. Not knowing whether someone is entitled is the state that existed before the level did, so
95 it places no ceiling; denying there would refuse MCP to every caller during a cold-cache fault."""
98def _parse_mcp_server_names_from_path(path: str, mcp_servers_header: list[str] | None = None) -> list[str] | None:
99 """Resolve the single MCP server name a cold-start passthrough bypass may
100 target. Delegates parsing to
101 :meth:`MCPRequestHandler.extract_target_server_names_from_path` so the
102 names used here always match the names downstream routing uses; returns
103 ``None`` whenever the bypass must not activate (aggregate ``/mcp``,
104 multi-server CSV paths, or any other unrecognized path).
106 Also fails closed when the ``x-mcp-servers`` header introduces any server
107 not present in the path-derived target set. Downstream routing for
108 ``/mcp/...`` paths overrides the header with path-derived names, but a
109 header/path mismatch here is a sign of a confused or hostile caller —
110 refuse the cold-start bypass rather than admit anonymously based on the
111 path while the header advertises a stricter, non-passthrough target."""
112 servers: Final = MCPRequestHandler.extract_target_server_names_from_path(path)
113 if len(servers) != 1: 113 ↛ 121line 113 didn't jump to line 121 because the condition on line 113 was always true
114 verbose_logger.debug(
115 "MCP cold-start: path %r resolved to %r; passthrough 401 bypass "
116 "requires exactly one target and will not activate",
117 path,
118 servers,
119 )
120 return None
121 if mcp_servers_header is not None and (set(mcp_servers_header) - set(servers)):
122 verbose_logger.debug(
123 "MCP cold-start: x-mcp-servers header %r introduces target(s) not "
124 "in path-derived set %r; passthrough 401 bypass will not activate",
125 mcp_servers_header,
126 servers,
127 )
128 return None
129 return servers
132def _is_mcp_passthrough_cold_start(mcp_servers: list[str] | None, client_ip: str | None) -> bool:
133 """True only when EVERY targeted server is a pass-through server with no
134 auth headers — the cold-start OAuth discovery case per RFC 9728 / MCP
135 Authorization spec. Lets the route handler's 401 emitter produce the
136 spec-compliant WWW-Authenticate challenge instead of surfacing a generic
137 admission error.
139 Uses "all" semantics: one non-passthrough target in a co-targeted set must
140 not flip the bypass open for the others. Fails closed when any target
141 cannot be resolved."""
142 if not mcp_servers:
143 return False
144 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
145 global_mcp_server_manager,
146 )
148 for name in mcp_servers:
149 server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)
150 if server is None or not getattr(server, "is_oauth_passthrough", False):
151 return False
152 return True
155def _is_legacy_delegate_cold_start(mcp_servers: list[str] | None, client_ip: str | None) -> bool:
156 """Allow only credential-free legacy delegates to reach the route's OAuth challenge."""
157 if not mcp_servers:
158 return False
159 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
160 MCPServerManager,
161 global_mcp_server_manager,
162 )
163 from litellm.types.mcp import MCPAuth
165 for name in mcp_servers:
166 server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)
167 if server is None or server.auth_type != MCPAuth.oauth2:
168 return False
169 if server.delegate_auth_to_upstream is not True:
170 return False
171 if MCPServerManager.effective_oauth2_flow(server) == "client_credentials":
172 return False
173 return True
176def _is_litellm_auth_admission_error(exc: Exception) -> bool:
177 if isinstance(exc, HTTPException): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 return exc.status_code == 401
179 if isinstance(exc, ProxyException): 179 ↛ 184line 179 didn't jump to line 184 because the condition on line 179 was always true
180 try:
181 return int(exc.code) == 401
182 except (TypeError, ValueError):
183 return False
184 return False
187def _explicit_credential_matches_envelope(
188 explicit_auth: UserAPIKeyAuth,
189 presented_token: str,
190 identity: EnvelopeIdentity,
191) -> bool:
192 """Match the stored key hash or user ID, including token-only mapped JWT keys."""
193 match identity.subject_type:
194 case "key_hash":
195 return identity.subject in (hash_token(presented_token), explicit_auth.token)
196 case "user_id":
197 return explicit_auth.user_id is not None and explicit_auth.user_id == identity.subject
198 return assert_never(identity.subject_type)
201def _has_client_supplied_mcp_auth(
202 mcp_auth_header: str | None,
203 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
204) -> bool:
205 return bool(mcp_auth_header) or bool(mcp_server_auth_headers)
208def _agent_capped_servers(
209 allowed_mcp_servers: Sequence[str],
210 agent_servers: Sequence[str],
211 agent_access_group_servers: frozenset[str] | None,
212) -> tuple[str, ...] | None:
213 if not agent_servers and agent_access_group_servers is None:
214 return None
215 return tuple(
216 s
217 for s in allowed_mcp_servers
218 if (not agent_servers or s in agent_servers)
219 and (agent_access_group_servers is None or s in agent_access_group_servers)
220 )
223def _is_mcp_admitted_user_subject(user_api_key_auth: UserAPIKeyAuth | None) -> bool:
224 """True when this auth is a keyless subject admitted by the gateway session / bridge user
225 path, as opposed to a JWT or other keyless auth that merely lacks a ``team_id``.
227 Reads the server-only ``mcp_admitted_user_subject`` field, set only by ``reload_admitted_user``. It
228 is deliberately NOT a ``metadata`` key, which is caller-controlled at key creation and so forgeable
229 on a personal key to gain the team grant union or dodge the egress scrub; this field cannot be."""
230 return user_api_key_auth is not None and user_api_key_auth.mcp_admitted_user_subject is True
233def _gateway_dcr_challenge_target(
234 route: str,
235 mcp_servers: list[str] | None,
236 client_ip: str | None,
237) -> str | None:
238 """Resolve a single path target whose sign-in metadata advertises the gateway."""
239 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
240 global_mcp_server_manager,
241 )
243 targets: Final = _parse_mcp_server_names_from_path(route, mcp_servers)
244 if targets is None: 244 ↛ 246line 244 didn't jump to line 246 because the condition on line 244 was always true
245 return None
246 server: Final = global_mcp_server_manager.get_mcp_server_by_name(targets[0], client_ip=client_ip)
247 if server is None or not server.advertises_gateway_authorization_server:
248 return None
249 return targets[0]
252def _is_gateway_dcr_challenge_scope(
253 route: str,
254 mcp_servers: list[str] | None,
255 mcp_auth_header: str | None,
256 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
257 exc: Exception,
258 client_ip: str | None,
259) -> bool:
260 """True when an unauthenticated MCP request should receive the RFC 9728 401
261 challenge that advertises the gateway as the authorization server.
263 Fires only for a genuine 401 with no client-supplied MCP auth headers (those mean
264 the caller is not a cold-start DCR client), on the scopes the gateway's keyless
265 flow serves: the aggregate ``/mcp`` endpoint, an ``x-mcp-servers``-scoped request
266 (the resource the client configured is still ``/mcp``), or a per-server path whose
267 single target advertises gateway-owned sign-in. Every other named target keeps
268 its existing behavior, failing closed to the original admission error."""
269 if not _is_litellm_auth_admission_error(exc):
270 return False
271 if _has_client_supplied_mcp_auth(mcp_auth_header, mcp_server_auth_headers): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 return False
273 if len(MCPRequestHandler.extract_target_server_names_from_path(route)) == 0: 273 ↛ 275line 273 didn't jump to line 275 because the condition on line 273 was always true
274 return True
275 return _gateway_dcr_challenge_target(route, mcp_servers, client_ip) is not None
278def _gateway_dcr_challenge(
279 request: Request,
280 route: str,
281 mcp_servers: list[str] | None,
282 invalid_token: bool,
283) -> HTTPException:
284 """The RFC 9728 challenge pointing the client at the protected-resource metadata
285 matching the scope it requested: the per-server document (same URL spelling the
286 request arrived on) when the single target advertises gateway-owned sign-in,
287 else the gateway's aggregate document. Either way the client discovers the gateway
288 as its authorization server and starts the same sign-in flow.
290 ``invalid_token`` adds the RFC 6750 error code for a request that DID
291 present a bearer that failed admission (expired or revoked), telling
292 spec-compliant clients to re-authorize rather than retry; a request with
293 no credentials at all gets the bare challenge per RFC 6750 section 3.1."""
294 target: Final = _gateway_dcr_challenge_target(route, mcp_servers, IPAddressUtils.get_mcp_client_ip(request))
295 resource_metadata_url: Final = (
296 get_passthrough_resource_metadata_url(request.scope, target)
297 if target is not None
298 else f"{get_request_base_url(request)}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp"
299 )
300 error_attr: Final = 'error="invalid_token", ' if invalid_token else ""
301 return HTTPException(
302 status_code=401,
303 detail={
304 "error": "authentication_required",
305 "message": "Authenticate with the gateway to use the MCP endpoint.",
306 },
307 headers={"WWW-Authenticate": f'Bearer {error_attr}resource_metadata="{resource_metadata_url}"'},
308 )
311def _admission_failure_fallback(
312 request: Request,
313 request_route: str,
314 mcp_servers: list[str] | None,
315 mcp_auth_header: str | None,
316 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
317 exc: Exception,
318 bearer_presented: bool,
319) -> UserAPIKeyAuth:
320 """Map a failed LiteLLM admission to its anonymous fallback or challenge.
322 Two fallbacks exist, both gated on a genuine 401 with no client-supplied
323 MCP auth headers. The pass-through cold start (RFC 9728 / MCP
324 Authorization spec discovery return) admits anonymously so the route's
325 401 emitter can produce the per-server challenge. The aggregate
326 gateway-DCR scope converts the failure into the gateway's own
327 resource_metadata challenge, with the RFC 6750 ``invalid_token`` error
328 code when the caller DID present a bearer (an expired gateway session
329 must re-authorize, not retry a dead token). Anything else re-raises the
330 original admission error unchanged."""
331 mcp_servers_from_path: Final = _parse_mcp_server_names_from_path(request_route, mcp_servers)
332 if ( 332 ↛ 350line 332 didn't jump to line 350 because the condition on line 332 was never true
333 mcp_servers_from_path is not None
334 and not _has_client_supplied_mcp_auth(mcp_auth_header, mcp_server_auth_headers)
335 and _is_litellm_auth_admission_error(exc)
336 and (
337 _is_mcp_passthrough_cold_start(
338 mcp_servers_from_path,
339 client_ip=IPAddressUtils.get_mcp_client_ip(request),
340 )
341 or (
342 not bearer_presented
343 and _is_legacy_delegate_cold_start(
344 mcp_servers_from_path,
345 client_ip=IPAddressUtils.get_mcp_client_ip(request),
346 )
347 )
348 )
349 ):
350 verbose_logger.debug("MCP pass-through cold start: deferring admission to route 401 emitter")
351 return UserAPIKeyAuth()
352 if _is_gateway_dcr_challenge_scope(
353 route=request_route,
354 mcp_servers=mcp_servers,
355 mcp_auth_header=mcp_auth_header,
356 mcp_server_auth_headers=mcp_server_auth_headers,
357 exc=exc,
358 client_ip=IPAddressUtils.get_mcp_client_ip(request),
359 ):
360 raise _gateway_dcr_challenge(request, request_route, mcp_servers, invalid_token=bearer_presented) from exc
361 raise exc
364@dataclass(frozen=True, slots=True)
365class MCPServerAccess:
366 server_ids: tuple[str, ...]
367 scope: Literal["unscoped", "scoped", "unresolved"] = "unscoped"
370@dataclass(frozen=True, slots=True)
371class DcrBridgeTarget:
372 """The single DCR-bridge server a request targets, paired with the exact name the caller
373 used to reach it (alias or server_name, whichever they typed), which is the spelling an
374 ``invalid_token`` challenge must echo back."""
376 requested_name: str
377 server: MCPServer
380class MCPRequestHandler:
381 """
382 Class to handle MCP request processing, including:
383 1. Authentication via LiteLLM API keys
384 2. MCP server configuration and routing
385 3. Header extraction and validation
387 Utilizes the main `user_api_key_auth` function to validate authentication
389 Entitlement-fault contract (``get_allowed_mcp_servers`` / ``get_allowed_tools_for_server``)
390 ------------------------------------------------------------------------------------------
391 Every level (key, team, end user, agent, org) answers "which servers/tools does this level
392 permit", and a level that answers nothing places no restriction. A lookup FAULT is not that
393 answer, and the two callers resolve it differently on purpose:
395 - A keyless gateway-admitted subject fails CLOSED on any fault at any level. Each of its grant
396 sources is resolved independently and unioned, so a fault that returned "no restriction" would
397 win the union as allow-all, and its per-source org ceiling is the ONLY org bound it has.
398 - Key auth fails closed only where there is POSITIVE evidence an entitlement exists: a principal
399 row that NAMES an ``object_permission_id`` we cannot load is a known entitlement with unknown
400 contents (``UnloadableEntitlementError`` -> deny). A fault so early we cannot tell whether the
401 principal is entitled at all leaves no ceiling, because that is the state that existed before
402 the level did; denying there would refuse MCP to every caller, most of whom have no entitlement
403 configured, for the duration of a cold-cache or DB fault.
404 """
406 LITELLM_API_KEY_HEADER_NAME_PRIMARY = SpecialHeaders.custom_litellm_api_key.value
407 LITELLM_API_KEY_HEADER_NAME_SECONDARY = SpecialHeaders.openai_authorization.value
409 # This is the header to use if you want LiteLLM to use this header for authenticating to the MCP server
410 LITELLM_MCP_AUTH_HEADER_NAME = SpecialHeaders.mcp_auth.value
412 LITELLM_MCP_SERVERS_HEADER_NAME = SpecialHeaders.mcp_servers.value
414 LITELLM_MCP_ACCESS_GROUPS_HEADER_NAME = SpecialHeaders.mcp_access_groups.value
416 @staticmethod
417 async def process_mcp_request(
418 scope: Scope,
419 ) -> tuple[
420 UserAPIKeyAuth,
421 str | None,
422 list[str] | None,
423 dict[str, dict[str, str]] | None,
424 dict[str, str] | None,
425 dict[str, str] | None,
426 ]:
427 """
428 Process and validate MCP request headers from the ASGI scope.
429 This includes:
430 1. Extracting and validating authentication headers
431 2. Processing MCP server configuration
432 3. Handling MCP-specific headers
433 4. Handling oauth2 headers
434 5. Raw headers - allows forwarding specific headers to the MCP server, specified by the admin.
436 Args:
437 scope: ASGI scope containing request information
439 Returns:
440 UserAPIKeyAuth containing validated authentication information
441 mcp_auth_header: Optional[str] MCP auth header to be passed to the MCP server (deprecated)
442 mcp_servers: Optional[List[str]] List of MCP servers and access groups to use
443 mcp_server_auth_headers: Optional[Dict[str, str]] Server-specific auth headers in format {server_alias: auth_value}
444 oauth2_headers: Optional[Dict[str, str]] OAuth2 headers
445 raw_headers: Optional[Dict[str, str]] Raw headers to be forwarded to the MCP server
446 Raises:
447 HTTPException: If headers are invalid or missing required headers
448 """
449 headers: Final = MCPRequestHandler._safe_get_headers_from_scope(scope)
451 # Check if there is an explicit LiteLLM API key (primary header)
452 has_explicit_litellm_key: Final = headers.get(MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY) is not None
454 litellm_api_key: Final = MCPRequestHandler.get_litellm_api_key_from_headers(headers) or ""
456 # Get the old mcp_auth_header for backward compatibility
457 mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers(headers)
459 # Get the new server-specific auth headers
460 mcp_server_auth_headers = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers)
462 # Get the oauth2 headers
463 oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(headers)
465 # Parse MCP servers from header
466 mcp_servers_header: Final = headers.get(MCPRequestHandler.LITELLM_MCP_SERVERS_HEADER_NAME)
467 verbose_logger.debug("Raw MCP servers header: %s", mcp_servers_header)
468 mcp_servers = None
469 if mcp_servers_header is not None: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 try:
471 mcp_servers = [s.strip() for s in mcp_servers_header.split(",") if s.strip()]
472 verbose_logger.debug("Parsed MCP servers: %s", mcp_servers)
473 except Exception as e:
474 verbose_logger.debug("Error parsing mcp_servers header: %s", e)
475 mcp_servers = None
476 if mcp_servers_header == "" or (mcp_servers is not None and len(mcp_servers) == 0):
477 mcp_servers = []
478 # Create a proper Request object with mock body method to avoid ASGI receive channel issues
479 request: Final = Request(scope=scope)
481 async def mock_body():
482 return b"{}"
484 request.body = mock_body
485 # Inline import — auth_utils participates in a proxy import cycle.
486 from litellm.proxy.auth.auth_utils import ( # noqa: PLC0415
487 get_request_route,
488 )
490 request_route: Final = get_request_route(request)
491 # Only OAuth metadata routes registered under /.well-known/ are public.
492 if request_route.startswith("/.well-known/"): 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true
493 validated_user_api_key_auth = UserAPIKeyAuth()
494 elif ( 494 ↛ 507line 494 didn't jump to line 507 because the condition on line 494 was never true
495 has_explicit_litellm_key
496 and oauth2_headers
497 and is_bridge_envelope_shaped(oauth2_headers["Authorization"])
498 and (
499 dual_bridge_target := MCPRequestHandler._single_dcr_bridge_delegate_target(
500 path=request_route,
501 mcp_servers=mcp_servers,
502 client_ip=IPAddressUtils.get_mcp_client_ip(request),
503 )
504 )
505 is not None
506 ):
507 (
508 validated_user_api_key_auth,
509 mcp_server_auth_headers,
510 ) = await MCPRequestHandler._admit_dcr_bridge_dual_credential(
511 server=dual_bridge_target.server,
512 requested_name=dual_bridge_target.requested_name,
513 authorization_value=oauth2_headers["Authorization"],
514 litellm_api_key=litellm_api_key,
515 mcp_server_auth_headers=mcp_server_auth_headers,
516 request=request,
517 route=request_route,
518 )
519 elif has_explicit_litellm_key:
520 # An explicit x-litellm-api-key is always a LiteLLM credential, even
521 # for a delegated server, so validate it: identity / spend / rate
522 # limits resolve and any stored upstream token can be forwarded.
523 validated_user_api_key_auth = await user_api_key_auth(
524 api_key=f"Bearer {_get_bearer_token_or_received_api_key(litellm_api_key)}",
525 request=request,
526 )
527 elif MCPRequestHandler._target_servers_are_true_passthrough( 527 ↛ 542line 527 didn't jump to line 542 because the condition on line 527 was never true
528 path=request_route,
529 mcp_servers=mcp_servers,
530 client_ip=IPAddressUtils.get_mcp_client_ip(request),
531 ) or (
532 MCPRequestHandler._single_dcr_bridge_delegate_target(
533 path=request_route,
534 mcp_servers=mcp_servers,
535 client_ip=IPAddressUtils.get_mcp_client_ip(request),
536 )
537 is not None
538 and not oauth2_headers
539 and not mcp_server_auth_headers
540 and not mcp_auth_header
541 ):
542 validated_user_api_key_auth = UserAPIKeyAuth()
543 elif ( 543 ↛ 550line 543 didn't jump to line 550 because the condition on line 543 was never true
544 bridge_delegate_target := MCPRequestHandler._single_dcr_bridge_delegate_target(
545 path=request_route,
546 mcp_servers=mcp_servers,
547 client_ip=IPAddressUtils.get_mcp_client_ip(request),
548 )
549 ) is not None and oauth2_headers:
550 (
551 validated_user_api_key_auth,
552 mcp_server_auth_headers,
553 ) = await MCPRequestHandler._admit_dcr_bridge_authorization(
554 server=bridge_delegate_target.server,
555 requested_name=bridge_delegate_target.requested_name,
556 authorization_value=oauth2_headers["Authorization"],
557 litellm_api_key=litellm_api_key,
558 mcp_server_auth_headers=mcp_server_auth_headers,
559 request=request,
560 route=request_route,
561 )
562 elif oauth2_headers and is_session_bearer_shaped(oauth2_headers["Authorization"]): 562 ↛ 569line 562 didn't jump to line 569 because the condition on line 562 was never true
563 # A gateway DCR session bearer at any MCP scope: open the identity-only session
564 # token and admit under the live litellm user; downstream grant resolution
565 # intersects the admitted subject's servers with any path or header target, so a
566 # per-server scope narrows and never broadens. One that does not open fails
567 # closed with the scope's invalid_token challenge; a non-session bearer falls
568 # through to the oauth2 arm.
569 validated_user_api_key_auth = await MCPRequestHandler._admit_gateway_session(
570 authorization_value=oauth2_headers["Authorization"],
571 request=request,
572 route=request_route,
573 mcp_servers=mcp_servers,
574 )
575 elif oauth2_headers: 575 ↛ 579line 575 didn't jump to line 579 because the condition on line 575 was never true
576 # Authorization on a non-delegated server: the bearer must be a real
577 # LiteLLM credential, so a failed validation is a genuine 401/403 and
578 # propagates unless a fallback in _admission_failure_fallback applies.
579 try:
580 validated_user_api_key_auth = await user_api_key_auth(api_key=litellm_api_key, request=request)
581 except (HTTPException, ProxyException) as e:
582 validated_user_api_key_auth = _admission_failure_fallback(
583 request=request,
584 request_route=request_route,
585 mcp_servers=mcp_servers,
586 mcp_auth_header=mcp_auth_header,
587 mcp_server_auth_headers=mcp_server_auth_headers,
588 exc=e,
589 bearer_presented=True,
590 )
591 else:
592 try:
593 validated_user_api_key_auth = await user_api_key_auth(api_key=litellm_api_key, request=request)
594 except (HTTPException, ProxyException) as exc:
595 validated_user_api_key_auth = _admission_failure_fallback(
596 request=request,
597 request_route=request_route,
598 mcp_servers=mcp_servers,
599 mcp_auth_header=mcp_auth_header,
600 mcp_server_auth_headers=mcp_server_auth_headers,
601 exc=exc,
602 bearer_presented=False,
603 )
605 # Leak-defense (single chokepoint): a gateway admission credential (session bearer or bridge
606 # envelope) is NEVER a valid upstream token. Scrub it from EVERY egress context so no
607 # client-forwarded, OBO, or passthrough path can send it upstream for replay. Anchored to the
608 # credential SHAPE, so a legitimate upstream/passthrough token is forwarded unchanged.
609 raw_headers = dict(headers)
610 (
611 oauth2_headers,
612 raw_headers,
613 mcp_auth_header,
614 mcp_server_auth_headers,
615 ) = MCPRequestHandler._scrub_gateway_admission_credentials(
616 admitted=_is_mcp_admitted_user_subject(validated_user_api_key_auth),
617 oauth2_headers=oauth2_headers,
618 raw_headers=raw_headers,
619 mcp_auth_header=mcp_auth_header,
620 mcp_server_auth_headers=mcp_server_auth_headers,
621 )
623 return (
624 validated_user_api_key_auth,
625 mcp_auth_header,
626 mcp_servers,
627 mcp_server_auth_headers,
628 oauth2_headers,
629 raw_headers,
630 )
632 @staticmethod
633 def _is_gateway_admission_credential(value: str | None) -> bool:
634 """True when a header value is a gateway admission credential — a session bearer or bridge
635 envelope. It proves who signed in to the GATEWAY, never a valid UPSTREAM token, so it must never
636 be forwarded (a hostile upstream could capture and replay it against the aggregate ``/mcp`` scope)."""
637 return value is not None and (is_session_bearer_shaped(value) or is_bridge_envelope_shaped(value))
639 @staticmethod
640 def _scrub_gateway_admission_credentials(
641 admitted: bool,
642 oauth2_headers: dict[str, str] | None,
643 raw_headers: dict[str, str],
644 mcp_auth_header: str | None,
645 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
646 ) -> tuple[dict[str, str] | None, dict[str, str], str | None, dict[str, dict[str, str]] | None]:
647 """Remove any gateway admission credential from EVERY egress header context, keyed on the credential
648 SHAPE: top-level ``Authorization`` (oauth2 + raw), the deprecated ``x-mcp-auth``, and per-server
649 ``x-mcp-{alias}-authorization``. A legitimate upstream/passthrough token is never gateway-shaped so
650 it survives (including the real upstream token the bridge arm injects per-server); an admitted
651 subject's top-level Authorization is dropped unconditionally as defense-in-depth."""
652 cred: Final = MCPRequestHandler._is_gateway_admission_credential
654 # 1. Top-level Authorization → oauth2_headers.
655 authz: Final = oauth2_headers.get("Authorization") if oauth2_headers else None
656 if admitted or cred(authz): 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true
657 oauth2_headers = None
659 # 2. raw_headers: drop the admitted subject's Authorization, and ANY header whose value is a
660 # gateway credential (covers x-mcp-auth and x-mcp-{alias}-authorization in their raw form).
661 raw_headers = {
662 k: v for k, v in raw_headers.items() if not ((admitted and k.lower() == "authorization") or cred(v))
663 }
665 # 3. Deprecated x-mcp-auth value.
666 if cred(mcp_auth_header): 666 ↛ 667line 666 didn't jump to line 667 because the condition on line 666 was never true
667 mcp_auth_header = None
669 # 4. Per-server x-mcp-{alias}-authorization values (drop the value, then any now-empty server dict).
670 if mcp_server_auth_headers: 670 ↛ 671line 670 didn't jump to line 671 because the condition on line 670 was never true
671 stripped: Final = {
672 alias: {h: val for h, val in hdrs.items() if not cred(val)}
673 for alias, hdrs in mcp_server_auth_headers.items()
674 }
675 mcp_server_auth_headers = {alias: hdrs for alias, hdrs in stripped.items() if hdrs}
677 return oauth2_headers, raw_headers, mcp_auth_header, mcp_server_auth_headers
679 @staticmethod
680 def extract_target_server_names_from_path(path: str) -> list[str]:
681 """
682 Extract the target MCP server name(s) from the standard MCP transport
683 URL patterns: ``/mcp/{server_name_or_csv}[/...]`` and
684 ``/{server_name}/mcp[/...]``. Returns ``[]`` for any other path so
685 callers fail closed when the target cannot be resolved.
687 Mirrors the regex-based parser in ``server.py::_get_mcp_servers_in_path``
688 so the names used for auth gating match the names used for downstream
689 filtering. Without this alignment, an attacker could craft
690 ``/mcp/<delegated_server>/<garbage>`` so that auth treats the request
691 as targeting the delegate server (bypassing LiteLLM auth) while
692 downstream filtering sees a different (non-existent) target and falls
693 back to the caller's full allowed-server set.
695 REST/admin endpoints, OAuth2 server endpoints
696 (``/{server_name}/authorize``, ``/token`` etc.), and ``.well-known``
697 discovery routes intentionally fall through — those flows do not need
698 OAuth2 token passthrough. Clients aggregating multiple servers should
699 use ``x-mcp-servers`` on a path that does not encode a target.
700 """
701 # ``/{server_name}/mcp[/...]`` form — single server. The literal
702 # ``mcp`` must be the second segment (not the first, which would be
703 # the ``/mcp/...`` form handled below). This branch must stay in sync
704 # with ``server.py::_get_mcp_servers_in_path``, which also accepts the
705 # un-rewritten form (some entry points may skip the
706 # ``dynamic_mcp_route`` rewrite).
707 if path.rstrip("/") in ("/mcp/sse", "/mcp/sse/messages"): 707 ↛ 708line 707 didn't jump to line 708 because the condition on line 707 was never true
708 return []
709 segments: Final = [s for s in path.split("/") if s]
710 if len(segments) >= 2 and segments[1] == "mcp" and segments[0] != "mcp": 710 ↛ 711line 710 didn't jump to line 711 because the condition on line 710 was never true
711 return [segments[0]]
713 # ``/mcp/...`` form — server name(s) may contain a slash (e.g.
714 # ``custom_solutions/user_123``) and may be a comma-separated list.
715 # Use the same parsing logic as ``_get_mcp_servers_in_path`` so the
716 # parsed names match downstream routing.
717 mcp_path_match: Final = re.match(r"^/mcp/([^?#]+)(?:\?.*)?(?:#.*)?$", path)
718 if not mcp_path_match: 718 ↛ 720line 718 didn't jump to line 720 because the condition on line 718 was always true
719 return []
720 servers_and_path: Final = mcp_path_match.group(1)
721 if not servers_and_path:
722 return []
724 if "," in servers_and_path:
725 # Comma-separated servers, possibly followed by a trailing path.
726 path_match: Final = re.search(r"/([^/,]+(?:/[^/,]+)*)$", servers_and_path)
727 if path_match:
728 servers_part = servers_and_path[: -(len(path_match.group(1)) + 1)]
729 else:
730 servers_part = servers_and_path
731 return [s.strip() for s in servers_part.split(",") if s.strip()]
733 # Single-server case — server name may contain at most one slash.
734 single_server_match: Final = re.match(r"^([^/]+(?:/[^/]+)?)(?:/.*)?$", servers_and_path)
735 if single_server_match:
736 return [single_server_match.group(1)]
737 return [servers_and_path]
739 @staticmethod
740 def _target_servers_are_true_passthrough(path: str, mcp_servers: list[str] | None, client_ip: str | None) -> bool:
741 """
742 True only when EVERY MCP server the request targets is ``auth_type == true_passthrough``.
743 Fails closed when any target does not opt in or cannot be resolved.
745 Used by :meth:`process_mcp_request` to skip LiteLLM admission auth entirely: the gateway is a
746 transparent proxy and the caller's ``Authorization`` is an upstream token, never a LiteLLM key.
747 A mixed-target request keeps normal auth.
748 """
749 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
750 global_mcp_server_manager,
751 )
752 from litellm.types.mcp import MCPAuth
754 target_names: Final = MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers)
755 if not target_names: 755 ↛ 758line 755 didn't jump to line 758 because the condition on line 755 was always true
756 return False
758 for name in target_names:
759 server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)
760 if server is None or server.auth_type != MCPAuth.true_passthrough:
761 return False
762 return True
764 @staticmethod
765 def _single_dcr_bridge_delegate_target(
766 path: str, mcp_servers: list[str] | None, client_ip: str | None
767 ) -> DcrBridgeTarget | None:
768 """The one DCR-bridge ``oauth_delegate`` server this request targets, or ``None``.
770 Returns the target only when EXACTLY ONE name resolves and its server is both
771 ``is_oauth_delegate`` and ``is_dcr_bridge``. Fails closed (``None``) on a
772 multi-target request, an unresolved target, or a non-matching server, so the
773 envelope admission arm never fires for an aggregate scope or a server that did not
774 opt into the bridge. Mirrors :meth:`_target_servers_are_true_passthrough`.
775 """
776 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
777 global_mcp_server_manager,
778 )
780 target_names: Final = MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers)
781 if len(target_names) != 1: 781 ↛ 783line 781 didn't jump to line 783 because the condition on line 781 was always true
782 return None
783 server: Final = global_mcp_server_manager.get_mcp_server_by_name(target_names[0], client_ip=client_ip)
784 # Both flags are security-sensitive opt-ins. Require literal booleans so
785 # partially populated objects and truthy proxy values cannot enable bridge
786 # admission accidentally.
787 if server is None or server.is_oauth_delegate is not True or server.is_dcr_bridge is not True:
788 return None
789 # Egress resolves the injected per-server token only by alias / server_name; a server with
790 # neither cannot receive the forwarded token, so fail closed rather than admit-and-drop.
791 if not (server.server_name or server.alias):
792 return None
793 return DcrBridgeTarget(requested_name=target_names[0], server=server)
795 @staticmethod
796 async def _admit_dcr_bridge_delegate(
797 server: MCPServer,
798 requested_name: str,
799 authorization_value: str,
800 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
801 request: Request,
802 route: str,
803 ) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]:
804 """Open the bridge envelope and admit the caller under the live key it references.
806 The envelope's signature proves the user authenticated when it was minted, but
807 authorization is resolved fresh here rather than trusted from the envelope: the
808 sealed ``key_hash`` reloads the current ``UserAPIKeyAuth`` record, and the admitted
809 identity then runs through the standard pipeline's centralized policy gate, so the
810 key's present restrictions and revocation state gate the request instead of a
811 snapshot frozen at mint time. The inner upstream token is injected under the
812 server's per-server auth-header key so egress forwards it via the
813 ``PassthroughConfig`` override; the envelope ``Authorization`` the leak-defense
814 strips never reaches the upstream. A new headers dict is returned rather than
815 mutating the input. Fails closed with a 401 on an invalid or expired envelope, or
816 when the referenced key is missing, blocked, or expired, its owner is
817 SCIM-deactivated, or the centralized policy gate rejects it (blocked team or
818 project, org or budget limits).
820 The sealed token is keyed alias-first, matching the order egress resolves
821 (``lookup_mcp_server_auth_in_headers`` tries ``alias`` before ``server_name``). Keying
822 under ``server_name`` would leave a caller-supplied ``x-mcp-{alias}-authorization`` at the
823 higher-priority alias slot, pairing the admitted identity with an attacker's upstream
824 credential; the alias-keyed injection overwrites any such caller value.
825 """
826 result: Final = await MCPRequestHandler._open_dcr_bridge_envelope(
827 server=server,
828 requested_name=requested_name,
829 authorization_value=authorization_value,
830 request=request,
831 route=route,
832 )
833 header_key: Final = server.alias or server.server_name
834 if header_key is None:
835 raise HTTPException(status_code=500, detail="Server misconfigured: MCP server has no routable name")
836 admitted: Final = await MCPRequestHandler._reload_admitted_principal(result.identity)
837 await MCPRequestHandler._enforce_admitted_live_policy(admitted=admitted, request=request, route=route)
838 injected: Final = { # mutable-ok: mcp_server_auth_headers contract requires concrete dicts
839 header_key: { # mutable-ok: concrete dict header payload
840 "Authorization": result.upstream_authorization.get_secret_value()
841 }
842 }
843 new_headers: Final = { # mutable-ok: merged header map must stay a concrete dict
844 **(mcp_server_auth_headers or {}), # mutable-ok: empty-dict fallback for the merge
845 **injected,
846 }
847 return admitted, new_headers
849 @staticmethod
850 async def _open_dcr_bridge_envelope(
851 server: MCPServer,
852 requested_name: str,
853 authorization_value: str,
854 request: Request,
855 route: str,
856 ) -> BridgeEnvelopeAdmitted:
857 """Open a bridge envelope after the pre-DB gates, or fail closed with the scope's challenge.
859 Shared by the envelope-only arm (:meth:`_admit_dcr_bridge_delegate`) and the dual-credential
860 arm (:meth:`_admit_dcr_bridge_dual_credential`): both require master_key, run the same
861 proxy-wide pre-DB checks the standard pipeline applies before any key lookup, and resolve
862 the envelope's crypto. Returns only the ``BridgeEnvelopeAdmitted`` result; an invalid,
863 expired, tampered, or non-envelope value raises the requested scope's ``invalid_token``
864 challenge instead."""
865 from litellm.proxy.proxy_server import master_key
867 if not master_key:
868 raise HTTPException(status_code=500, detail="Server misconfigured: master_key is not set")
870 await MCPRequestHandler._run_pre_db_read_auth_checks(request=request, route=route)
872 keys: Final = envelope_keys_from_master_key(master_key)
873 result: Final = resolve_bridge_envelope(authorization_value, keys, datetime.now(timezone.utc), server.server_id)
874 match result:
875 case BridgeEnvelopeAdmitted():
876 return result
877 case BridgeEnvelopeInvalid() | NotBridgeEnvelope():
878 raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(
879 requested_name=requested_name, request=request
880 )
881 return assert_never(result)
883 @staticmethod
884 async def _admit_dcr_bridge_dual_credential(
885 server: MCPServer,
886 requested_name: str,
887 authorization_value: str,
888 litellm_api_key: str,
889 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
890 request: Request,
891 route: str,
892 ) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]:
893 """Admit a request carrying BOTH an explicit litellm credential and a bridge envelope.
895 MCP clients send ``x-litellm-api-key`` on every request, including the ``tools/list`` that
896 follows the ``/{server}/token`` mint, so the envelope arrives alongside the key rather than
897 alone. The explicit credential is validated first (its own pipeline, so a bad key keeps the
898 normal 401/403), then the envelope is opened and its sealed identity must match the explicit
899 credential's principal — a mismatch is a 403, never a fallback onto either credential alone.
900 On a match the explicit credential's ``UserAPIKeyAuth`` is the admission context (key
901 permissions, budgets, rate limits) and the sealed upstream token is injected under the
902 server's per-server auth-header key, while the leak-defense chokepoint strips the envelope
903 ``Authorization`` itself from egress."""
904 presented_token: Final = _get_bearer_token_or_received_api_key(litellm_api_key)
905 explicit_auth: Final = await user_api_key_auth(api_key=f"Bearer {presented_token}", request=request)
906 result: Final = await MCPRequestHandler._open_dcr_bridge_envelope(
907 server=server,
908 requested_name=requested_name,
909 authorization_value=authorization_value,
910 request=request,
911 route=route,
912 )
913 if not _explicit_credential_matches_envelope(
914 explicit_auth=explicit_auth,
915 presented_token=presented_token,
916 identity=result.identity,
917 ):
918 raise HTTPException(
919 status_code=403,
920 detail={ # mutable-ok: HTTPException detail payload requires a concrete dict
921 "error": "oauth_principal_mismatch"
922 },
923 )
924 header_key: Final = server.alias or server.server_name
925 if header_key is None:
926 raise HTTPException(status_code=500, detail="Server misconfigured: MCP server has no routable name")
927 injected: Final = { # mutable-ok: mcp_server_auth_headers contract requires concrete dicts
928 header_key: { # mutable-ok: concrete dict header payload
929 "Authorization": result.upstream_authorization.get_secret_value()
930 }
931 }
932 new_headers: Final = { # mutable-ok: merged header map must stay a concrete dict
933 **(mcp_server_auth_headers or {}), # mutable-ok: empty-dict fallback for the merge
934 **injected,
935 }
936 return explicit_auth, new_headers
938 @staticmethod
939 async def _admit_dcr_bridge_authorization(
940 server: MCPServer,
941 requested_name: str,
942 authorization_value: str,
943 litellm_api_key: str,
944 mcp_server_auth_headers: dict[str, dict[str, str]] | None, # mutable-ok: existing MCP sink shape
945 request: Request,
946 route: str,
947 ) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]: # mutable-ok: existing MCP sink shape
948 if is_bridge_envelope_shaped(authorization_value):
949 return await MCPRequestHandler._admit_dcr_bridge_delegate(
950 server=server,
951 requested_name=requested_name,
952 authorization_value=authorization_value,
953 mcp_server_auth_headers=mcp_server_auth_headers,
954 request=request,
955 route=route,
956 )
957 try:
958 admitted: Final = await user_api_key_auth(api_key=litellm_api_key, request=request)
959 except (HTTPException, ProxyException) as exc:
960 if not _is_litellm_auth_admission_error(exc):
961 raise
962 raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(
963 requested_name=requested_name, request=request
964 ) from exc
965 return admitted, mcp_server_auth_headers
967 @staticmethod
968 def _dcr_bridge_invalid_token_challenge(requested_name: str, request: Request) -> HTTPException:
969 """The RFC 6750 ``invalid_token`` challenge for a failed bridge admission.
971 Named by the exact spelling the caller requested, matching the per-server well-known
972 document and the other challenge emitters, so ``resource_metadata`` always points at the
973 resource the client actually asked for even when alias and server_name differ."""
974 return HTTPException(
975 status_code=401,
976 detail="Invalid or expired credential",
977 headers=MappingProxyType(
978 {
979 "www-authenticate": get_passthrough_www_authenticate(
980 scope=request.scope,
981 server_name=requested_name,
982 invalid_token=True,
983 )
984 }
985 ),
986 )
988 @staticmethod
989 async def _admit_gateway_session(
990 authorization_value: str,
991 request: Request,
992 route: str,
993 mcp_servers: list[str] | None,
994 ) -> UserAPIKeyAuth:
995 """Open a gateway DCR session bearer and admit the live litellm user it references.
997 Identity-only sibling of :meth:`_admit_dcr_bridge_delegate`: the session token seals no
998 upstream credential (those are vaulted per user, resolved at egress), so authorization is
999 resolved fresh via :meth:`reload_admitted_user` + the centralized policy gate rather than a
1000 mint-time snapshot. Pre-DB gates (size, IP, route allowlist) run first, mirroring the standard
1001 pipeline. Fails closed with the requested scope's ``invalid_token`` challenge on an expired,
1002 tampered, foreign, or refresh token, or a missing/deactivated/policy-rejected user."""
1003 from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
1004 NotSessionBearer,
1005 SessionBearerAdmitted,
1006 SessionBearerInvalid,
1007 SessionSigningConfigError,
1008 active_session_signing_keys,
1009 resolve_session_bearer,
1010 )
1011 from litellm.proxy.proxy_server import master_key
1013 if not master_key:
1014 raise HTTPException(status_code=500, detail="Server misconfigured: master_key is not set")
1016 await MCPRequestHandler._run_pre_db_read_auth_checks(request=request, route=route)
1018 keys: Final = active_session_signing_keys(master_key)
1019 if isinstance(keys, SessionSigningConfigError):
1020 verbose_logger.error("mcp gateway session admission rejected: %s", keys.detail)
1021 raise HTTPException(status_code=500, detail="Server misconfigured: mcp_session_token_signing is invalid")
1022 result: Final = resolve_session_bearer(authorization_value, keys, datetime.now(timezone.utc))
1023 match result:
1024 case SessionBearerAdmitted():
1025 try:
1026 admitted: Final = await MCPRequestHandler.reload_admitted_user(result.principal.user_id)
1027 admitted.mcp_session_resource_server_id = result.principal.resource_server_id
1028 await MCPRequestHandler._enforce_admitted_live_policy(
1029 admitted=admitted, request=request, route=route
1030 )
1031 except HTTPException as exc:
1032 # A cryptographically valid bearer whose referenced user is now missing or
1033 # SCIM-deactivated is an invalid_token at the requested scope: relay the RFC 9728
1034 # challenge so the DCR client re-authorizes, matching the SessionBearerInvalid
1035 # arm, instead of a bare 401 with no WWW-Authenticate. A 503 (DB outage) is a
1036 # transient availability failure, not an auth failure, so it passes through.
1037 if exc.status_code == 401:
1038 raise _gateway_dcr_challenge(request, route, mcp_servers, invalid_token=True) from exc
1039 raise
1040 return admitted
1041 case SessionBearerInvalid():
1042 raise _gateway_dcr_challenge(request, route, mcp_servers, invalid_token=True)
1043 case NotSessionBearer():
1044 # Unreachable: the arm is entered only for an is_session_bearer_shaped
1045 # value. Kept for match exhaustiveness and fails closed regardless.
1046 raise _gateway_dcr_challenge(request, route, mcp_servers, invalid_token=True)
1047 case _:
1048 assert_never(result)
1050 @staticmethod
1051 async def _run_pre_db_read_auth_checks(request: Request, route: str) -> None:
1052 """Run the proxy-wide gates ``user_api_key_auth`` applies before any key lookup: the
1053 request-size and body-safety limits, the IP allowlist, and the ``general_settings``
1054 route allowlist. The envelope arm bypasses ``user_api_key_auth`` (it opens the envelope
1055 and reloads the identity itself), so without this a caller blocked by IP or hitting a
1056 proxy route the allowlist forbids would be admitted through an envelope where the same
1057 principal presented on the normal MCP admission path would be rejected. Runs before the
1058 envelope crypto so a disallowed caller is turned away before any work, mirroring the
1059 standard pipeline's pre-DB ordering. Violations raise the gate's own status (an IP or
1060 route block is a 403, an oversized body its own limit error)."""
1061 from litellm.proxy.auth.auth_utils import pre_db_read_auth_checks
1063 await pre_db_read_auth_checks(
1064 request=request,
1065 request_data=await _read_request_body(request=request),
1066 route=route,
1067 )
1069 @staticmethod
1070 async def _reload_admitted_principal(identity: EnvelopeIdentity) -> UserAPIKeyAuth:
1071 """Reload the live litellm record the envelope's subject references.
1073 Dispatches on the sealed subject type: a ``key_hash`` reloads the virtual key that
1074 minted the envelope (the scripted two-header client that presents a litellm key at the
1075 token endpoint), a ``user_id`` reloads the user that authenticated interactively (the
1076 DCR client, whose SSO login at the bridged authorize yields a user, not a key). Both
1077 return a ``UserAPIKeyAuth`` the caller runs through the centralized policy gate, so
1078 team/project/org/budget/SCIM enforcement is identical to the principal presenting
1079 itself directly."""
1080 match identity.subject_type:
1081 case "key_hash":
1082 return await MCPRequestHandler._reload_admitted_key(identity.subject)
1083 case "user_id":
1084 return await MCPRequestHandler.reload_admitted_user(identity.subject)
1085 case _:
1086 assert_never(identity.subject_type)
1088 @staticmethod
1089 async def reload_admitted_user(user_id: str) -> UserAPIKeyAuth:
1090 """Reload the live user an interactively-minted envelope references and admit them as themselves.
1092 The user's own object permission and ``org_id`` ride on the returned ``UserAPIKeyAuth``, and the
1093 SAME ``get_allowed_mcp_servers`` the key path uses gates the request. The ``mcp_admitted_user_subject``
1094 marker (set below) makes that resolver union the servers the user reaches through ANY of their teams
1095 on top of these direct grants, each source bounded by ITS OWN org, so a user spanning organizations
1096 cannot leak one org's servers past another's ceiling.
1098 Error handling: ``get_user_object`` lets a database outage propagate as-is and re-raises every other
1099 DB failure as a bare ``ValueError`` (the cause surviving only as ``__context__``).
1100 ``_raise_503_if_db_unavailable`` walks the cause chain so an outage stays a retryable 503 whichever
1101 shape it arrives in, while any other failure fails closed as 401, not an opaque 500; the
1102 object-permission load shares that boundary."""
1103 from litellm.proxy.auth.auth_checks import get_object_permission, get_user_object
1104 from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
1106 if prisma_client is None:
1107 raise HTTPException(status_code=500, detail="Server misconfigured: no database connection")
1108 try:
1109 user_object: Final = await get_user_object(
1110 user_id=user_id,
1111 prisma_client=prisma_client,
1112 user_api_key_cache=user_api_key_cache,
1113 user_id_upsert=False,
1114 )
1115 # Resolve the user's own MCP object permission (get_user_object does not load it) so the shared
1116 # get_allowed_mcp_servers can grant the user their litellm-granted servers. Reuses the same
1117 # get_object_permission resolver the key and team paths use; no permission logic is duplicated.
1118 object_permission = user_object.object_permission if user_object is not None else None
1119 if user_object is not None and object_permission is None and user_object.object_permission_id:
1120 object_permission = await get_object_permission(
1121 object_permission_id=user_object.object_permission_id,
1122 prisma_client=prisma_client,
1123 user_api_key_cache=user_api_key_cache,
1124 )
1125 except (ProxyException, HTTPException):
1126 raise HTTPException(status_code=401, detail="Invalid or expired credential") from None
1127 except Exception as e: # noqa: BLE001 # a DB outage anywhere in the resolution is a retryable 503, not an opaque 500; anything else fails closed as 401
1128 MCPRequestHandler._raise_503_if_db_unavailable(e)
1129 raise HTTPException(status_code=401, detail="Invalid or expired credential") from None
1130 if user_object is None:
1131 raise HTTPException(status_code=401, detail="Invalid or expired credential")
1132 if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False:
1133 raise HTTPException(status_code=401, detail="Invalid or expired credential")
1134 admitted: Final = UserAPIKeyAuth(
1135 user_id=user_object.user_id,
1136 user_role=user_object.user_role,
1137 org_id=user_object.organization_id,
1138 object_permission=object_permission,
1139 object_permission_id=user_object.object_permission_id,
1140 # Copy the live user's rate limits, as the standard user-subject path does: the parallel
1141 # limiter reads these off the auth object and treats None as unlimited, so a keyless subject
1142 # with them unset would outrun its user RPM/TPM. (Per-team mcp_rpm_limit is stamped below;
1143 # per-KEY limits do not apply, there being no key.)
1144 user_tpm_limit=user_object.tpm_limit,
1145 user_rpm_limit=user_object.rpm_limit,
1146 )
1147 # Server-only marker, set AFTER construction: the before-validator strips it from any validated
1148 # input, so caller-supplied data (key metadata, JWT claims) can never forge it.
1149 admitted.mcp_admitted_user_subject = True
1150 # Carry each granting team's per-server mcp_rpm_limit: this subject reaches servers through
1151 # several teams under its own identity, so without this a cross-team user outruns every team's
1152 # limit. Resolved from the same roster-checked sources as the grant union, so a team throttles
1153 # only what it granted.
1154 admitted.mcp_source_team_rpm_limits = await MCPRequestHandler._admitted_subject_team_rpm_limits(admitted)
1155 return admitted
1157 @staticmethod
1158 async def _admitted_subject_team_rpm_limits(auth: UserAPIKeyAuth) -> dict[str, dict[str, int]] | None:
1159 """``team_id -> mcp_rpm_limit`` for every team this subject reaches servers through, each map
1160 filtered to the servers THAT team's grant actually reaches.
1162 A limit rides the same scope as the access it bounds, so a roster team is charged only for a
1163 server its OWN grant reaches (never one the user reaches through a different team, which would
1164 drain a bucket shared by that team's keys for access it never provided). Grant scope comes from
1165 the SAME ``get_allowed_mcp_servers(source)`` authorization uses; limit-map keys are names/aliases
1166 so each is resolved to an id via ``expand_permission_list`` before the membership check. Returns
1167 None (no descriptors) when nothing applies; a lookup failure narrows to None rather than raising,
1168 since rate limiting must not deny a request authorization already allowed."""
1169 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
1170 global_mcp_server_manager,
1171 )
1173 try:
1174 limits: Final[dict[str, dict[str, int]]] = {}
1175 source_grants: Final = await MCPRequestHandler.admitted_source_grants(auth)
1176 for source, granted_ids in source_grants:
1177 if not source.team_id:
1178 continue
1179 team_obj = await MCPRequestHandler._roster_team_object(source.team_id, auth)
1180 team_limit = (team_obj.metadata or {}).get("mcp_rpm_limit") if team_obj is not None else None
1181 if not isinstance(team_limit, dict) or not team_limit:
1182 continue
1183 applicable: dict[str, int] = {}
1184 for server_name, rpm in team_limit.items():
1185 for server_id in global_mcp_server_manager.expand_permission_list([server_name]):
1186 if server_id not in granted_ids:
1187 continue
1188 # Charge ONLY the source billing attributes the call to (same owner), so one
1189 # cross-team user cannot drain several teams' shared buckets on a single call,
1190 # and a server the user's OWN grant reaches charges no team bucket.
1191 attributed = await MCPRequestHandler.attributing_source_for_server(
1192 auth, server_id, source_grants=source_grants
1193 )
1194 if attributed is not None and attributed.team_id == source.team_id:
1195 applicable[server_name] = rpm
1196 break
1197 if applicable:
1198 limits[source.team_id] = applicable
1199 return limits or None
1200 except Exception as e: # noqa: BLE001 # throttling metadata must never fail an allowed request
1201 verbose_logger.warning("Failed to resolve per-team MCP rpm limits for admitted subject: %s", e)
1202 return None
1204 @staticmethod
1205 async def _reload_admitted_key(key_hash: str) -> UserAPIKeyAuth:
1206 """Reload the live key record an admitted envelope references and re-check live policy.
1208 Resolving the current ``UserAPIKeyAuth`` (cache first, then DB) is what stops the
1209 envelope from carrying frozen authority: the key's present team/org/object-permission
1210 restrictions ride on the returned object, and a key that has since been deleted,
1211 blocked, or expired fails closed with a 401 here rather than being admitted as an
1212 unrestricted identity. ``get_key_object`` raises for a hash with no key row; a
1213 blocked or expired row is rejected explicitly because ``get_key_object`` resolves a
1214 row without applying those checks (the main ``user_api_key_auth`` pipeline enforces
1215 them downstream, which this admission path bypasses). The owner's SCIM state is the
1216 other builder-inline check mirrored here, so IdP offboarding revokes every envelope
1217 minted under the user's keys rather than leaving them live until expiry. Team,
1218 project, org, and budget state are NOT re-checked here; the caller runs the admitted
1219 identity through ``_enforce_admitted_live_policy`` for those.
1220 """
1221 from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
1222 master_key_admin_auth, # noqa: PLC0415 # inline import avoids a module-load circular import
1223 )
1224 from litellm.proxy.auth.auth_checks import get_key_object
1225 from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
1227 admin: Final = master_key_admin_auth(key_hash)
1228 if admin is not None:
1229 return admin
1230 if prisma_client is None:
1231 raise HTTPException(status_code=500, detail="Server misconfigured: no database connection")
1232 try:
1233 key_object: Final = await get_key_object(
1234 hashed_token=key_hash,
1235 prisma_client=prisma_client,
1236 user_api_key_cache=user_api_key_cache,
1237 )
1238 except (ProxyException, HTTPException):
1239 raise HTTPException(status_code=401, detail="Invalid or expired credential") from None
1240 except Exception as e: # noqa: BLE001 # a DB outage during reload is a retryable 503, not an opaque 500
1241 MCPRequestHandler._raise_503_if_db_unavailable(e)
1242 raise
1243 if not MCPRequestHandler._admitted_key_is_active(key_object):
1244 raise HTTPException(status_code=401, detail="Invalid or expired credential")
1245 await MCPRequestHandler._reject_if_admitted_owner_scim_deactivated(key_object)
1246 return key_object
1248 @staticmethod
1249 def _raise_503_if_db_unavailable(e: Exception) -> None:
1250 """Raise a retryable 503 when ``e`` means the auth database is unreachable, else return so the
1251 caller applies its own fail-closed mapping. A DB outage must not masquerade as an auth failure
1252 (401) or surface as an opaque 500; the caller retries. Mirrors ``UserAPIKeyAuthExceptionHandler``,
1253 which renders a service-unavailable database error as 503 on the standard pipeline.
1255 Classifies across the ``__cause__``/``__context__`` chain, not just ``e`` itself, so an outage a
1256 caller re-raised inside a domain exception is still recognized."""
1257 from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
1259 outage: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(e)
1260 if outage is not None:
1261 raise HTTPException(
1262 status_code=503,
1263 detail=PrismaDBExceptionHandler.database_unavailable_message(outage),
1264 ) from None
1266 @staticmethod
1267 async def _reject_if_admitted_owner_scim_deactivated(key_object: UserAPIKeyAuth) -> None:
1268 """Fail closed with a 401 when the key's owning user was deactivated via SCIM.
1270 The standard pipeline enforces this inline in ``_user_api_key_auth_builder`` rather
1271 than in ``common_checks``, so the centralized policy gate does not cover it; without
1272 this mirror, IdP offboarding would leave the user's already-minted envelopes live
1273 until expiry. A failed user lookup skips the gate (fail-open), matching the builder:
1274 this is the one deliberately fail-open check in an otherwise fail-closed arm, so a
1275 transient DB outage during this lookup admits the request rather than rejecting it,
1276 keeping parity with how the standard pipeline treats the same lookup failure."""
1277 if key_object.user_id is None:
1278 return
1279 from litellm.proxy.auth.auth_checks import get_user_object
1280 from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
1282 try:
1283 user_object = await get_user_object(
1284 user_id=key_object.user_id,
1285 prisma_client=prisma_client,
1286 user_api_key_cache=user_api_key_cache,
1287 user_id_upsert=False,
1288 )
1289 except Exception as e: # noqa: BLE001 # mirror the builder's fail-open user lookup; DB errors are of any type
1290 verbose_logger.debug("bridge admission: user lookup failed, skipping SCIM gate: %s", e)
1291 user_object = None
1292 if user_object is None or not isinstance(user_object.metadata, dict):
1293 return
1294 if user_object.metadata.get("scim_active") is False:
1295 raise HTTPException(status_code=401, detail="Invalid or expired credential")
1297 @staticmethod
1298 async def _enforce_admitted_live_policy(admitted: UserAPIKeyAuth, request: Request, route: str) -> None:
1299 """Run the standard pipeline's authorization checks over the admitted identity.
1301 Mirrors the ``user_api_key_auth`` wrapper between the builder and its return: clear the
1302 request-scoped ``budget_reservation`` on the reloaded identity, run the route gate
1303 (``RouteChecks.should_call_route``) to enforce the identity's ``allowed_routes`` and any
1304 disabled/admin-only route, then run ``_run_centralized_common_checks`` (the same gate every
1305 builder path funnels through) for team-block, project-block, org, and budget. The route gate
1306 closes a bypass: a key barred from MCP routes could otherwise mint an envelope at the token
1307 endpoint (not itself an MCP route) and replay it against MCP, because the centralized checks
1308 treat MCP as an inference route and never re-check ``allowed_routes``.
1310 Failures surface with the status the standard pipeline would give them, mirroring
1311 ``UserAPIKeyAuthExceptionHandler``: a disallowed route is the route gate's own 403, an
1312 over-budget identity is a 422, a sub-check that raised its own ``HTTPException``/
1313 ``ProxyException`` keeps that status, a transient database outage is a retryable 503, and
1314 only a genuinely unresolvable failure (a blocked team/project raises a bare ``Exception``,
1315 same as the standard pipeline's fallback) becomes the fail-closed 401. Collapsing every
1316 failure to 401 was misleading: it told an over-budget but validly-authenticated caller their
1317 credential was invalid, which on a DCR client reads as broken auth and can trigger a
1318 pointless re-authorize loop that cannot fix a budget problem, and it masked a DB outage as an
1319 auth error."""
1320 from litellm.proxy.auth.route_checks import RouteChecks
1322 admitted.budget_reservation = None
1323 try:
1324 RouteChecks.should_call_route(route=route, valid_token=admitted, request=request)
1325 await _run_centralized_common_checks(
1326 user_api_key_auth_obj=admitted,
1327 request=request,
1328 request_data=await _read_request_body(request=request),
1329 route=route,
1330 )
1331 except (HTTPException, ProxyException):
1332 raise
1333 except litellm.BudgetExceededError as e:
1334 raise HTTPException(status_code=getattr(e, "status_code", 429), detail=str(e)) from None
1335 except Exception as e: # noqa: BLE001 # untyped gate failure: retryable 503 for a DB outage, else fail closed 401
1336 MCPRequestHandler._raise_503_if_db_unavailable(e)
1337 raise HTTPException(status_code=401, detail="Invalid or expired credential") from None
1339 @staticmethod
1340 def _admitted_key_is_active(key_object: UserAPIKeyAuth) -> bool:
1341 """False when the referenced key is blocked or past its expiry, so a revoked key
1342 cannot be admitted through its still-unexpired envelope. Mirrors the active-key gate
1343 the bridge token endpoint applies at mint time."""
1344 if key_object.blocked is True:
1345 return False
1346 expires: Final = key_object.expires
1347 if expires is None:
1348 return True
1349 expiry = expires if isinstance(expires, datetime) else datetime.fromisoformat(expires)
1350 if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None:
1351 expiry = expiry.replace(tzinfo=timezone.utc)
1352 return expiry >= datetime.now(timezone.utc)
1354 @staticmethod
1355 def _resolve_target_server_names(path: str, mcp_servers_header: list[str] | None) -> list[str]:
1356 """
1357 Resolve the target MCP server names exactly as downstream routing
1358 does (``server.py::extract_mcp_auth_context``).
1360 For ``/mcp/...`` paths, downstream routing **overrides** any
1361 ``x-mcp-servers`` header value with the path-derived names. Mirror
1362 that here so an attacker cannot use a permissive header value to
1363 flip an auth gate while the path targets a stricter server
1364 (header/path TOCTOU). For non-``/mcp/...`` paths (where the path
1365 does not encode targets), fall back to the header.
1366 """
1367 path_targets: Final = MCPRequestHandler.extract_target_server_names_from_path(path)
1368 if path_targets: 1368 ↛ 1369line 1368 didn't jump to line 1369 because the condition on line 1368 was never true
1369 return path_targets
1370 # Path did not resolve to /mcp/... targets — trust the header
1371 # (including an explicitly empty list, which means "no targets").
1372 return mcp_servers_header if mcp_servers_header is not None else []
1374 @staticmethod
1375 def _get_mcp_auth_header_from_headers(headers: Headers) -> str | None:
1376 """
1377 Get the header passed to LiteLLM to pass to downstream MCP servers
1379 By default litellm will check for the header `x-mcp-auth` by setting one of the following:
1380 1. `LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME` as an environment variable
1381 2. `mcp_client_side_auth_header_name` in the general settings on the config.yaml file
1383 Support this auth: https://docs.litellm.ai/docs/mcp#using-your-mcp-with-client-side-credentials
1385 If you want to use a different header name, you can set the `LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME` in the secret manager or `mcp_client_side_auth_header_name` in the general settings.
1387 DEPRECATED: This method is deprecated in favor of server-specific auth headers using the format x-mcp-{{server_alias}}-{{header_name}} instead.
1388 """
1389 mcp_client_side_auth_header_name: Final[str] = MCPRequestHandler.get_mcp_client_side_auth_header_name()
1390 auth_header: Final = headers.get(mcp_client_side_auth_header_name)
1391 if auth_header: 1391 ↛ 1392line 1391 didn't jump to line 1392 because the condition on line 1391 was never true
1392 verbose_logger.warning(
1393 "The '%s' header is deprecated. Please use server-specific auth headers in the format 'x-mcp-{server_alias}-{header_name}' instead.",
1394 mcp_client_side_auth_header_name,
1395 )
1396 return auth_header
1398 @staticmethod
1399 def _get_mcp_server_auth_headers_from_headers(
1400 headers: Headers,
1401 ) -> dict[str, dict[str, str]]:
1402 """
1403 Parse server-specific MCP auth headers from the request headers.
1405 Looks for headers in the format: x-mcp-{server_alias}-{header_name}
1406 Examples:
1407 - x-mcp-github-authorization: Bearer token123
1408 - x-mcp-zapier-x-api-key: api_key_456
1409 - x-mcp-deepwiki-authorization: Basic base64_encoded_creds
1411 Returns:
1412 Dict[str, Dict[str, str]]: Mapping of server alias to header dict
1413 """
1414 server_auth_headers: Final[dict[str, dict[str, str]]] = {}
1415 prefix: Final = "x-mcp-"
1417 for header_name, header_value in headers.items():
1418 if header_name.lower().startswith(prefix): 1418 ↛ 1420line 1418 didn't jump to line 1420 because the condition on line 1418 was never true
1419 # Skip the access groups header as it's not a server auth header
1420 if (
1421 header_name.lower() == MCPRequestHandler.LITELLM_MCP_ACCESS_GROUPS_HEADER_NAME.lower()
1422 or header_name.lower() == MCPRequestHandler.LITELLM_MCP_SERVERS_HEADER_NAME.lower()
1423 ):
1424 continue
1426 # Extract server_alias and header_name from x-mcp-{server_alias}-{header_name}
1427 remaining = header_name[len(prefix) :].lower()
1428 if "-" in remaining:
1429 # Split on the first dash to separate server_alias from header_name
1430 parts = remaining.split("-", 1)
1431 if len(parts) == 2:
1432 server_alias, auth_header_name = parts
1434 # Convert common header names to proper case
1435 if auth_header_name == "authorization":
1436 auth_header_name = "Authorization"
1438 # Initialize server dict if not exists
1439 if server_alias not in server_auth_headers:
1440 server_auth_headers[server_alias] = {}
1442 server_auth_headers[server_alias][auth_header_name] = header_value
1443 verbose_logger.debug(
1444 "Found server auth header: %s -> %s: %s...",
1445 server_alias,
1446 auth_header_name,
1447 header_value[:10],
1448 )
1450 return server_auth_headers
1452 @staticmethod
1453 def _get_oauth2_headers_from_headers(headers: Headers) -> dict[str, str]:
1454 """
1455 Get the oauth2 headers from the request headers.
1456 """
1457 oauth2_headers: Final = {}
1458 for header_name, header_value in headers.items():
1459 if header_name.lower().startswith("authorization"): 1459 ↛ 1460line 1459 didn't jump to line 1460 because the condition on line 1459 was never true
1460 oauth2_headers["Authorization"] = header_value
1461 return oauth2_headers
1463 @staticmethod
1464 def get_mcp_client_side_auth_header_name() -> str:
1465 """
1466 Get the header name used to pass the MCP auth header to the MCP server
1468 By default litellm will check for the header `x-mcp-auth` by setting one of the following:
1469 1. `LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME` as an environment variable
1470 2. `mcp_client_side_auth_header_name` in the general settings on the config.yaml file
1471 """
1472 from litellm.proxy.proxy_server import general_settings
1473 from litellm.secret_managers.main import get_secret_str
1475 MCP_CLIENT_SIDE_AUTH_HEADER_NAME: str = MCPRequestHandler.LITELLM_MCP_AUTH_HEADER_NAME
1476 if get_secret_str("LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME") is not None: 1476 ↛ 1477line 1476 didn't jump to line 1477 because the condition on line 1476 was never true
1477 MCP_CLIENT_SIDE_AUTH_HEADER_NAME = (
1478 get_secret_str("LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME") or MCP_CLIENT_SIDE_AUTH_HEADER_NAME
1479 )
1480 elif general_settings.get("mcp_client_side_auth_header_name") is not None: 1480 ↛ 1481line 1480 didn't jump to line 1481 because the condition on line 1480 was never true
1481 MCP_CLIENT_SIDE_AUTH_HEADER_NAME = (
1482 general_settings.get("mcp_client_side_auth_header_name") or MCP_CLIENT_SIDE_AUTH_HEADER_NAME
1483 )
1484 return MCP_CLIENT_SIDE_AUTH_HEADER_NAME
1486 @staticmethod
1487 def get_litellm_api_key_from_headers(headers: Headers) -> str | None:
1488 """
1489 Get the Litellm API key from the headers using case-insensitive lookup
1491 1. Check if `x-litellm-api-key` is in the headers
1492 2. If not, check if `Authorization` is in the headers
1494 Args:
1495 headers: Starlette Headers object that handles case insensitivity
1496 """
1497 # Headers object handles case insensitivity automatically
1498 api_key: Final = headers.get(MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY)
1499 if api_key:
1500 return api_key
1502 auth_header: Final = headers.get(MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_SECONDARY)
1503 if auth_header: 1503 ↛ 1504line 1503 didn't jump to line 1504 because the condition on line 1503 was never true
1504 return auth_header
1506 return None
1508 @staticmethod
1509 def _safe_get_headers_from_scope(scope: Scope) -> Headers:
1510 """
1511 Safely extract headers from ASGI scope using Starlette's Headers class
1512 which handles case insensitivity and proper header parsing.
1514 ASGI headers are in format: List[List[bytes, bytes]]
1515 We need to convert them to the format Headers expects.
1517 Collapsing the ASGI list into a dict keeps the last value for a duplicated
1518 header name, so a request carrying more than one ``Authorization`` is
1519 rejected first: for the client-forwarded token modes the gateway relays the
1520 caller's ``Authorization`` upstream, so a duplicate would make which token is
1521 forwarded ambiguous (and diverge from what admission inspected). Multiple
1522 ``Authorization`` headers is malformed for bearer auth anyway (RFC 9110: not
1523 a comma-combinable field), so fail closed with a 400.
1524 """
1525 raw_headers: Final = scope.get("headers", [])
1526 MCPRequestHandler._reject_duplicate_authorization(raw_headers)
1527 try:
1528 # ASGI headers are list of [name: bytes, value: bytes] pairs
1529 # Convert bytes to strings and create dict for Headers constructor
1530 headers_dict: Final = {name.decode("latin-1"): value.decode("latin-1") for name, value in raw_headers}
1531 return Headers(headers_dict)
1532 except (UnicodeDecodeError, AttributeError, TypeError) as e:
1533 verbose_logger.exception("Error getting headers from scope: %s", e)
1534 # Return empty Headers object with empty dict
1535 return Headers({})
1537 @staticmethod
1538 def _reject_duplicate_authorization(raw_headers: object) -> None:
1539 """Raise 400 when the raw ASGI headers carry more than one ``Authorization`` header."""
1540 if not isinstance(raw_headers, (list, tuple)): 1540 ↛ 1541line 1540 didn't jump to line 1541 because the condition on line 1540 was never true
1541 return
1542 count = 0
1543 for entry in raw_headers:
1544 if not isinstance(entry, (list, tuple)) or len(entry) < 1: 1544 ↛ 1545line 1544 didn't jump to line 1545 because the condition on line 1544 was never true
1545 continue
1546 name = entry[0]
1547 if ( 1547 ↛ 1553line 1547 didn't jump to line 1553 because the condition on line 1547 was never true
1548 isinstance(name, (bytes, bytearray))
1549 and bytes(name).lower() == b"authorization"
1550 or isinstance(name, str)
1551 and name.lower() == "authorization"
1552 ):
1553 count += 1
1554 if count > 1: 1554 ↛ 1555line 1554 didn't jump to line 1555 because the condition on line 1554 was never true
1555 raise HTTPException(
1556 status_code=400,
1557 detail="Multiple Authorization headers are not allowed",
1558 )
1560 @staticmethod
1561 async def get_allowed_mcp_servers(
1562 user_api_key_auth: UserAPIKeyAuth | None = None,
1563 *,
1564 keyless_source: bool = False,
1565 ) -> list[str]:
1566 access: Final = await MCPRequestHandler.get_mcp_server_access(
1567 user_api_key_auth,
1568 keyless_source=keyless_source,
1569 )
1570 return list(access.server_ids)
1572 @staticmethod
1573 async def get_mcp_server_access(
1574 user_api_key_auth: UserAPIKeyAuth | None = None,
1575 *,
1576 keyless_source: bool = False,
1577 ) -> MCPServerAccess:
1578 """
1579 Get list of allowed MCP servers for the given user/key based on permissions.
1581 Permission hierarchy (all rules are intersections):
1582 1. Get allowed servers from key permissions
1583 2. Get allowed servers from team permissions (key inherits from team, or
1584 intersection; or inherits nothing when require_key_mcp_access_defined
1585 is enabled, making the team a ceiling rather than a default)
1586 3. Get allowed servers from end_user permissions (intersected if set)
1587 4. Get allowed servers from agent permissions (intersected if set)
1588 5. Get allowed servers from org permissions — org acts as a ceiling: if the org
1589 has an explicit MCP server list, the combined key/team/end_user/agent result is
1590 capped to that list. If the org has no list, no extra restriction is applied.
1592 A level that cannot answer is NOT a level that permits everything; see the class docstring
1593 for how each caller shape resolves an entitlement fault.
1595 Returns:
1596 List[str]: List of allowed MCP servers by server id
1597 """
1598 from litellm.proxy.proxy_server import general_settings
1600 key_object_permission: Final = MCPRequestHandler._get_key_object_permission(user_api_key_auth)
1602 try:
1603 # A keyless admitted subject resolves per source BEFORE any single-source rule here. Ordering
1604 # matters: the no_mcp_servers opt-out below reads the caller's own object_permission, so above
1605 # this branch a user's own opt-out would wrongly zero their TEAMS' grants too (each source is
1606 # independent; an opt-out silences only its own source, inside the recursive call).
1607 if _is_mcp_admitted_user_subject(user_api_key_auth) and user_api_key_auth is not None: 1607 ↛ 1608line 1607 didn't jump to line 1608 because the condition on line 1607 was never true
1608 return MCPServerAccess(
1609 server_ids=tuple(await MCPRequestHandler._resolve_admitted_subject_servers(user_api_key_auth)),
1610 )
1612 # Get allowed servers from key and team
1613 allowed_mcp_servers_for_key = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth)
1615 # The key explicitly opted out of every MCP server. This overrides
1616 # team inheritance and additive grants (mirrors no-default-models).
1617 if SpecialMCPServerNames.no_mcp_servers.value in allowed_mcp_servers_for_key: 1617 ↛ 1618line 1617 didn't jump to line 1618 because the condition on line 1617 was never true
1618 return MCPServerAccess(server_ids=(), scope="scoped")
1620 allowed_mcp_servers_for_team = await MCPRequestHandler._get_allowed_mcp_servers_for_team(user_api_key_auth)
1622 key_access_group_grants = await MCPRequestHandler._get_key_access_group_mcp_server_extras(user_api_key_auth)
1624 #########################################################
1625 # Calculate key/team allowed servers using inheritance and intersection logic
1626 #########################################################
1627 key_set: Final = set(allowed_mcp_servers_for_key)
1628 team_set: Final = set(allowed_mcp_servers_for_team)
1629 grants_set: Final = set(key_access_group_grants)
1631 # A DECLARED toolset restricts even when it resolves to no servers: the org
1632 # ceiling below may only cap it, never substitute the org's full server list.
1633 has_lower_level_mcp_restrictions = bool(key_set or team_set or grants_set) or (
1634 await MCPRequestHandler._key_or_team_declares_toolsets(user_api_key_auth)
1635 )
1637 # 1. Key/team ceiling. An empty set means "this level does not restrict".
1638 if not team_set: 1638 ↛ 1640line 1638 didn't jump to line 1640 because the condition on line 1638 was always true
1639 base = key_set # no team restriction
1640 elif not key_set:
1641 # A key that grants no MCP servers of its own inherits the
1642 # team's by default. With require_key_mcp_access_defined the
1643 # team is a ceiling rather than a default, so the key must
1644 # grant servers explicitly (or via an access group) to reach
1645 # any — it inherits none. That ceiling is for VIRTUAL KEYS that
1646 # can declare their own access; a keyless gateway/bridge-admitted
1647 # user has no key to declare access on — team membership IS their
1648 # only access path — so the flag must not zero their team grants.
1649 # A keyless admitted subject returned above and never reaches this virtual-key ceiling,
1650 # so require_key_mcp_access_defined can only ever zero a real key's inherited team grants.
1651 # ``keyless_source`` marks one grant source of an admitted subject, which has no key
1652 # to declare access on, so the flag must not zero its team grants.
1653 require_key_access: Final = (
1654 general_settings.get("require_key_mcp_access_defined", False) and not keyless_source
1655 )
1656 base = team_set if not require_key_access else set()
1657 else:
1658 base = key_set & team_set # both restrict → intersect
1660 # 2. Add the key's access-group grants on top. These are additive:
1661 # attaching a group to the key grants its servers regardless of the
1662 # team ceiling.
1663 allowed_mcp_servers: list[str] = list(base | grants_set)
1665 #########################################################
1666 # Check end_user permissions if end_user_id is set
1667 #########################################################
1668 if user_api_key_auth and user_api_key_auth.end_user_id: 1668 ↛ 1669line 1668 didn't jump to line 1669 because the condition on line 1668 was never true
1669 allowed_mcp_servers_for_end_user: Final = await MCPRequestHandler._get_allowed_mcp_servers_for_end_user(
1670 user_api_key_auth
1671 )
1673 # If end_user has explicit MCP server permissions, apply intersection
1674 if len(allowed_mcp_servers_for_end_user) > 0:
1675 has_lower_level_mcp_restrictions = True
1676 verbose_logger.debug(
1677 "End user %s has explicit MCP permissions: %s",
1678 user_api_key_auth.end_user_id,
1679 allowed_mcp_servers_for_end_user,
1680 )
1682 # Always apply intersection: key/team AND end_user
1683 # This ensures end_user can only access servers that both they AND their key/team are authorized for
1684 filtered_servers: Final = []
1685 for _mcp_server in allowed_mcp_servers:
1686 if _mcp_server in allowed_mcp_servers_for_end_user:
1687 filtered_servers.append(_mcp_server)
1688 allowed_mcp_servers = filtered_servers
1689 verbose_logger.debug(
1690 "Applied end_user intersection filter. Final allowed servers: %s", allowed_mcp_servers
1691 )
1692 # If flag is enabled but end_user has no permissions, block all access
1693 elif general_settings.get("require_end_user_mcp_access_defined", False):
1694 verbose_logger.debug(
1695 "require_end_user_mcp_access_defined=True and end_user %s has no MCP permissions - blocking MCP access",
1696 user_api_key_auth.end_user_id,
1697 )
1698 return MCPServerAccess(server_ids=(), scope="scoped")
1700 #########################################################
1701 # Check agent permissions if agent_id is set on the key
1702 #########################################################
1703 if user_api_key_auth and user_api_key_auth.agent_id: 1703 ↛ 1704line 1703 didn't jump to line 1704 because the condition on line 1703 was never true
1704 agent_capped: Final = _agent_capped_servers(
1705 allowed_mcp_servers,
1706 await MCPRequestHandler._get_allowed_mcp_servers_for_agent(user_api_key_auth),
1707 await MCPRequestHandler._get_agent_access_group_server_ceiling(user_api_key_auth),
1708 )
1709 if agent_capped is not None:
1710 has_lower_level_mcp_restrictions = True
1711 allowed_mcp_servers = list(agent_capped)
1712 verbose_logger.debug(
1713 "Applied agent intersection filter. Final allowed servers: %s", allowed_mcp_servers
1714 )
1716 #########################################################
1717 # Cap an agent key at what the user and team that invoked the agent may reach
1718 #########################################################
1719 caller_capped, caller_restricts = await MCPRequestHandler._apply_agent_caller_ceiling(
1720 allowed_mcp_servers, user_api_key_auth
1721 )
1723 #########################################################
1724 # Apply the internal user's own ceiling (the entitlement attached to the human)
1725 #########################################################
1726 capped, user_restricts = await MCPRequestHandler._apply_user_server_ceiling(
1727 caller_capped, user_api_key_auth, keyless_source=keyless_source
1728 )
1729 allowed_mcp_servers = list(capped)
1730 has_lower_level_mcp_restrictions = has_lower_level_mcp_restrictions or caller_restricts or user_restricts
1732 #########################################################
1733 # Apply org-level ceiling if org_id is set
1734 #########################################################
1735 allowed_mcp_servers, org_restricts = await MCPRequestHandler._apply_primary_org_ceiling(
1736 allowed_mcp_servers,
1737 user_api_key_auth,
1738 has_lower_level_mcp_restrictions,
1739 keyless_source=keyless_source,
1740 )
1742 declares_key_mcp_scope: Final = getattr(key_object_permission, "mcp_servers", None) is not None
1743 return MCPServerAccess(
1744 server_ids=tuple(set(allowed_mcp_servers)),
1745 scope=(
1746 "scoped"
1747 if has_lower_level_mcp_restrictions or org_restricts or declares_key_mcp_scope
1748 else "unscoped"
1749 ),
1750 )
1751 except Exception as e:
1752 if isinstance(e, UnloadableEntitlementError):
1753 # A ceiling we KNOW exists and cannot read. Denying is the only answer that does not
1754 # widen this caller past what an operator configured, for both caller shapes.
1755 verbose_logger.warning("Denying MCP access, entitlement unreadable: %s", e)
1756 else:
1757 verbose_logger.warning("Failed to get allowed MCP servers: %s", e)
1758 return MCPServerAccess(
1759 server_ids=(),
1760 scope="scoped" if getattr(key_object_permission, "mcp_servers", None) is not None else "unresolved",
1761 )
1763 @staticmethod
1764 async def _apply_primary_org_ceiling(
1765 allowed_mcp_servers: list[str],
1766 user_api_key_auth: UserAPIKeyAuth | None,
1767 has_lower_level_mcp_restrictions: bool,
1768 keyless_source: bool = False,
1769 ) -> tuple[list[str], bool]:
1770 """Cap the resolved server list by this caller's org ceiling: an explicit org list intersects
1771 lower-level restrictions (else becomes the ceiling); no org or an empty list leaves it unchanged.
1773 ``keyless_source`` governs both divergences for a keyless admitted source. An INDETERMINATE ceiling
1774 (we cannot tell whether the org restricts at all) fails CLOSED for it (its only org bound is this
1775 ceiling, so dropping it on a fault would escalate a cross-org user) while a key stays fail-open. And
1776 an org list may only ever INTERSECT a source (the admitted model unions grants, so a ceiling must not
1777 become one), whereas for a key it may substitute, that being the key ceiling model.
1779 The fail-open arm is reached only for an INDETERMINATE fault: a ceiling the org NAMES but that
1780 cannot be read raises out of ``_get_allowed_mcp_servers_for_org`` and never arrives here as
1781 ``None``, so key auth cannot silently shed a ceiling an operator did configure."""
1782 if not (user_api_key_auth and user_api_key_auth.org_id): 1782 ↛ 1784line 1782 didn't jump to line 1784 because the condition on line 1782 was always true
1783 return allowed_mcp_servers, False
1784 allowed_mcp_servers_for_org: Final = await MCPRequestHandler._get_allowed_mcp_servers_for_org(user_api_key_auth)
1785 if allowed_mcp_servers_for_org is None:
1786 verbose_logger.warning(
1787 "MCP org ceiling unresolved for org_id=%r; %s",
1788 user_api_key_auth.org_id,
1789 "denying (keyless admitted subject)" if keyless_source else "leaving uncapped (key auth)",
1790 )
1791 return ([] if keyless_source else allowed_mcp_servers), False
1792 if len(allowed_mcp_servers_for_org) == 0:
1793 return allowed_mcp_servers, False
1794 if has_lower_level_mcp_restrictions or keyless_source:
1795 # Org can only cap lower-level restrictions. A keyless admitted source ALWAYS takes this
1796 # arm: its model unions GRANTS, so an org list may only narrow a source, never become one.
1797 capped = [s for s in allowed_mcp_servers if s in allowed_mcp_servers_for_org]
1798 else:
1799 # No lower-level restrictions → org list becomes the ceiling.
1800 capped = allowed_mcp_servers_for_org
1801 verbose_logger.debug("Applied org ceiling filter. Final allowed servers: %s", capped)
1802 return capped, True
1804 @staticmethod
1805 def _scoped_source_auth(
1806 auth: UserAPIKeyAuth,
1807 *,
1808 team_id: str | None,
1809 org_id: str | None,
1810 carry_user_grants: bool,
1811 ) -> UserAPIKeyAuth:
1812 """A plain, UNMARKED auth describing ONE grant source of an admitted subject.
1814 Only the fields the resolver consults are carried; everything else is left at its default on
1815 purpose: no ``api_key``/``token`` (not a key), no budget/spend/rate-limit (the subject's own
1816 user-level limits meter the request, and per-source copies would double descriptors), no
1817 ``user_role`` (an admin role would grant every server at the server-manager wrapper). The
1818 admission marker cannot be set via the constructor (a before-validator pops it), so each source
1819 resolves as an ordinary caller and cannot re-enter the admitted path."""
1820 scoped: Final = UserAPIKeyAuth(
1821 user_id=auth.user_id,
1822 team_id=team_id,
1823 org_id=org_id,
1824 parent_otel_span=auth.parent_otel_span,
1825 )
1826 if carry_user_grants:
1827 # The user's OWN grants. A team source carries none of these (the resolver loads the team's
1828 # own object_permission from team_id); mixing them in would widen the team with grants it never made.
1829 scoped.object_permission = auth.object_permission
1830 scoped.object_permission_id = auth.object_permission_id
1831 scoped.access_group_ids = auth.access_group_ids
1832 return scoped
1834 @staticmethod
1835 async def _admitted_subject_sources(auth: UserAPIKeyAuth) -> list[UserAPIKeyAuth]:
1836 """The independent sources a keyless admitted subject reaches MCP servers through: their own
1837 direct grants, plus every team they are a live roster member of.
1839 Each team source carries that TEAM's org (falling back to the user's), so the canonical resolver
1840 applies the team's OWN owning-org ceiling — a cross-org user's teams are each bounded by their
1841 own org, not the caller's home org. Roster membership is checked HERE (not per resolution)
1842 because a user's cached ``teams`` array can name a team whose ``members_with_roles`` no longer
1843 lists them; the roster is the source of truth for revocation."""
1844 from litellm.proxy.proxy_server import prisma_client
1846 sources: Final = [
1847 MCPRequestHandler._scoped_source_auth(auth, team_id=None, org_id=auth.org_id, carry_user_grants=True)
1848 ]
1849 if not auth.user_id or prisma_client is None:
1850 return sources
1851 for team_id in await MCPRequestHandler._resolve_user_team_ids(auth.user_id, auth):
1852 team_obj = await MCPRequestHandler._roster_team_object(team_id, auth)
1853 if team_obj is None:
1854 continue
1855 sources.append(
1856 MCPRequestHandler._scoped_source_auth(
1857 auth,
1858 team_id=team_id,
1859 org_id=team_obj.organization_id or auth.org_id,
1860 carry_user_grants=False,
1861 )
1862 )
1863 return sources
1865 @staticmethod
1866 async def _roster_team_object(team_id: str, auth: UserAPIKeyAuth) -> LiteLLM_TeamTable | None:
1867 """The team row for ``team_id``, but ONLY when ``auth``'s user is a live roster member of it.
1869 The single owner of "is this team really one of this subject's sources", so the grant union
1870 and the per-team rate limits cannot disagree about which teams count. A team lingering in the
1871 user's cached ``teams`` array whose ``members_with_roles`` no longer lists them returns None
1872 here, which is what revokes both its grants and its throttle in one place."""
1873 from litellm.proxy.auth.auth_checks import get_team_object
1874 from litellm.proxy.proxy_server import (
1875 prisma_client,
1876 proxy_logging_obj,
1877 user_api_key_cache,
1878 )
1880 if prisma_client is None or not auth.user_id:
1881 return None
1882 try:
1883 team_obj: Final[LiteLLM_TeamTable | None] = await get_team_object(
1884 team_id=team_id,
1885 prisma_client=prisma_client,
1886 user_api_key_cache=user_api_key_cache,
1887 parent_otel_span=auth.parent_otel_span,
1888 proxy_logging_obj=proxy_logging_obj,
1889 )
1890 except Exception as e: # noqa: BLE001 # per-source isolation: one team's blip must not deny the others
1891 # Fault isolation is per SOURCE: an unresolvable team contributes nothing (fail closed for
1892 # it alone, access only narrows) while every other source stands. Raising would collapse the
1893 # whole union to deny-all over one momentarily-unreadable row.
1894 verbose_logger.warning("MCP admitted-subject source team %r unresolvable, skipping: %s", team_id, e)
1895 return None
1896 if team_obj is None:
1897 return None
1898 member_user_ids: Final = {getattr(m, "user_id", None) for m in (team_obj.members_with_roles or [])} - {None}
1899 if auth.user_id not in member_user_ids:
1900 return None
1901 # A team (or its owning org) over budget is not a live grantor, exactly as it is not for a key
1902 # pinned to it. Enforced via the SAME owners the key path uses (_team_max_budget_check /
1903 # _organization_max_budget_check), targeted at the TEAM's org through the scoped source view, so
1904 # no consumer of the source list ever sees an over-budget team. This is ENFORCEMENT of an
1905 # already-exceeded state; ATTRIBUTION of new spend stays with the user (documented deferral).
1906 from litellm.exceptions import BudgetExceededError
1907 from litellm.proxy.auth.auth_checks import (
1908 _organization_max_budget_check,
1909 _team_max_budget_check,
1910 )
1912 source_view: Final = MCPRequestHandler._scoped_source_auth(
1913 auth, team_id=team_id, org_id=team_obj.organization_id or auth.org_id, carry_user_grants=False
1914 )
1915 try:
1916 await _team_max_budget_check(
1917 team_object=team_obj, valid_token=source_view, proxy_logging_obj=proxy_logging_obj
1918 )
1919 await _organization_max_budget_check(
1920 valid_token=source_view,
1921 team_object=team_obj,
1922 prisma_client=prisma_client,
1923 user_api_key_cache=user_api_key_cache,
1924 proxy_logging_obj=proxy_logging_obj,
1925 )
1926 except BudgetExceededError as e:
1927 verbose_logger.info("MCP admitted-subject source team %r over budget, not a grantor: %s", team_id, e)
1928 return None
1929 except Exception as e: # noqa: BLE001 # per-source isolation: a budget-check fault narrows, never raises
1930 verbose_logger.warning("MCP budget check failed for source team %r, skipping source: %s", team_id, e)
1931 return None
1932 return team_obj
1934 @staticmethod
1935 async def admitted_source_grants(auth: UserAPIKeyAuth) -> list[tuple[UserAPIKeyAuth, set[str]]]:
1936 """``(source, the servers that source grants)`` for every source of an admitted subject.
1938 THE owner of "which source reaches which server". The reachable union, the per-team throttle
1939 scope, the tool union and billing attribution are all just different reads of this one
1940 answer — computing it separately per consumer is how they drift (a throttle map scoped by
1941 roster instead of by grant charged unrelated teams' buckets)."""
1942 return [
1943 (source, set(await MCPRequestHandler.get_allowed_mcp_servers(source, keyless_source=True)))
1944 for source in await MCPRequestHandler._admitted_subject_sources(auth)
1945 ]
1947 @staticmethod
1948 async def _resolve_admitted_subject_servers(auth: UserAPIKeyAuth) -> list[str]:
1949 """Union of what each of the admitted subject's sources reaches, each answered by the
1950 canonical resolver so no rule is reimplemented for this caller shape."""
1951 reachable: Final[set[str]] = set()
1952 for _source, granted in await MCPRequestHandler.admitted_source_grants(auth):
1953 reachable.update(granted)
1954 return list(reachable)
1956 @staticmethod
1957 async def billing_auth_for_tool_call(auth: UserAPIKeyAuth, tool_name: str) -> UserAPIKeyAuth:
1958 """The auth object a tool call's SPEND should be recorded against.
1960 ``auth`` unchanged for any non-admitted caller (key/JWT billing byte-identical). For an admitted
1961 subject whose call is reached through a team's grant, a copy carrying that team's ``team_id`` and
1962 owning ``org_id`` so the team's budget accumulates and the right org is charged. Falls back to
1963 user-level attribution (rather than guessing a team) when the tool name does not resolve to a
1964 server, reusing the manager's own tool-name lookup."""
1965 if not _is_mcp_admitted_user_subject(auth):
1966 return auth
1967 try:
1968 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
1969 global_mcp_server_manager,
1970 )
1972 server: Final = global_mcp_server_manager._get_mcp_server_from_tool_name(tool_name)
1973 if server is None:
1974 return auth
1975 source: Final = await MCPRequestHandler.attributing_source_for_server(auth, server.server_id)
1976 if source is None or not source.team_id:
1977 return auth
1978 billed: Final = auth.model_copy()
1979 billed.team_id = source.team_id
1980 billed.org_id = source.org_id
1981 return billed
1982 except Exception as e: # noqa: BLE001 # attribution must never fail an authorized call
1983 verbose_logger.warning("MCP billing attribution failed for %r, billing the user: %s", tool_name, e)
1984 return auth
1986 @staticmethod
1987 async def attributing_source_for_server(
1988 auth: UserAPIKeyAuth,
1989 server_id: str,
1990 source_grants: list[tuple[UserAPIKeyAuth, set[str]]] | None = None,
1991 ) -> UserAPIKeyAuth | None:
1992 """The source a billable call to ``server_id`` is attributed to, or None to bill the caller as
1993 themselves (their own grant reaches it, or nothing does).
1995 The rule: a user's OWN grant is not "through a team", so it bills the user; otherwise the call
1996 bills a granting team, deterministically the lowest ``team_id`` when several grant the server so
1997 the pick is stable rather than dict-ordering-dependent. Reads the one grant owner, so the billed
1998 team is always one that actually granted the server (restoring the team budget accrual and
1999 owning-org charge that a keyless, team_id-less subject otherwise skipped)."""
2000 source_grants = source_grants or await MCPRequestHandler.admitted_source_grants(auth)
2001 granting: Final = [(source, granted) for source, granted in source_grants if server_id in granted]
2002 if not granting:
2003 return None
2004 for source, _granted in granting:
2005 if source.team_id is None:
2006 return None # the user's own grant reaches it: their spend, their org
2007 return min((source for source, _ in granting), key=lambda s: s.team_id or "")
2009 @staticmethod
2010 async def _resolve_admitted_subject_tools(server_id: str, auth: UserAPIKeyAuth) -> list[str] | None:
2011 """Effective tool allowlist on ``server_id`` for an admitted subject, as the union over the
2012 sources that actually grant that server.
2014 A source that does not grant the server contributes nothing, so its tool rules cannot leak
2015 onto a server reached through a different source. A source that grants the server with no
2016 tool restriction means the user can use every tool on it, so allow-all wins the union. When
2017 no source grants the server the result is ``[]`` — deny all, fail closed."""
2018 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2019 global_mcp_server_manager,
2020 )
2022 # An OPEN channel (allow_all_keys, the user's own BYOM, an unscoped admin-view role) makes the
2023 # server REACHABLE through the user, though no grant source names it — without this the union
2024 # returns [], listable but uninvokable. Reachability is ALL it confers, NOT a ceiling waiver:
2025 # the user's own mcp_tool_permissions and org tool ceiling still bind, exactly as a key's do
2026 # on an allow_all server or an admin key's do on any server.
2027 reachable_via_open_channel: Final = server_id in await global_mcp_server_manager.operator_open_server_ids(
2028 auth
2029 ) or await MCPRequestHandler.admin_view_unscoped(auth)
2031 allowed: Final[set[str]] = set()
2032 for source, granted in await MCPRequestHandler.admitted_source_grants(auth):
2033 # The open channel is evaluated against the user's OWN source (team_id is None), so that
2034 # source's restrictions apply to it; a team's rules never ride an open-channel server.
2035 if server_id not in granted and not (reachable_via_open_channel and source.team_id is None):
2036 continue
2037 tools = await MCPRequestHandler.get_allowed_tools_for_server(server_id, source, keyless_source=True)
2038 if tools is None:
2039 return None
2040 allowed.update(tools)
2041 return sorted(allowed)
2043 @staticmethod
2044 def _get_key_object_permission(
2045 user_api_key_auth: UserAPIKeyAuth | None = None,
2046 ):
2047 """
2048 Get key object_permission - already loaded by get_key_object() in main auth flow.
2050 Note: object_permission is automatically populated when the key is fetched via
2051 get_key_object() in litellm/proxy/auth/auth_checks.py
2052 """
2053 if not user_api_key_auth: 2053 ↛ 2054line 2053 didn't jump to line 2054 because the condition on line 2053 was never true
2054 return None
2056 return user_api_key_auth.object_permission
2058 @staticmethod
2059 async def _get_team_object_permission(
2060 user_api_key_auth: UserAPIKeyAuth | None = None,
2061 ) -> LiteLLM_ObjectPermissionTable | None:
2062 """
2063 Get team object_permission - automatically loaded by get_team_object() in main auth flow.
2065 Note: object_permission is automatically populated when the team is fetched via
2066 get_team_object() in litellm/proxy/auth/auth_checks.py
2067 """
2068 from litellm.proxy.auth.auth_checks import get_team_object
2069 from litellm.proxy.proxy_server import (
2070 prisma_client,
2071 proxy_logging_obj,
2072 user_api_key_cache,
2073 )
2075 verbose_logger.debug(
2076 "MCP team permission lookup: team_id=%s", user_api_key_auth.team_id if user_api_key_auth else None
2077 )
2078 if not user_api_key_auth or not user_api_key_auth.team_id or not prisma_client: 2078 ↛ 2081line 2078 didn't jump to line 2081 because the condition on line 2078 was always true
2079 return None
2081 if user_api_key_auth.team_id == UI_TEAM_ID:
2082 return None
2084 # Get the team object (which has object_permission already loaded)
2085 team_obj: Final[LiteLLM_TeamTable | None] = await get_team_object(
2086 team_id=user_api_key_auth.team_id,
2087 prisma_client=prisma_client,
2088 user_api_key_cache=user_api_key_cache,
2089 parent_otel_span=user_api_key_auth.parent_otel_span,
2090 proxy_logging_obj=proxy_logging_obj,
2091 )
2093 if not team_obj:
2094 return None
2096 return team_obj.object_permission
2098 @staticmethod
2099 async def _toolset_tool_permissions(
2100 object_permission: LiteLLM_ObjectPermissionTable | None,
2101 ) -> Mapping[str, Sequence[str]]:
2102 """The ``server_id -> tool names`` grants of this permission row's toolsets, empty when it
2103 declares none. The shared resolver for the team, org, and internal-user levels, so a toolset
2104 behaves identically wherever it is attached.
2106 RAISES ``UnloadableEntitlementError`` when the row DECLARES toolsets but resolution yields
2107 nothing (deleted or unknown ids, a swallowed DB fault, or a toolset with no tools): that is a
2108 KNOWN restriction with unknown contents, and every caller already turns this error into deny
2109 rather than letting the level read as unrestricted."""
2110 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2111 global_mcp_server_manager,
2112 )
2114 if object_permission is None or not object_permission.mcp_toolsets: 2114 ↛ 2116line 2114 didn't jump to line 2116 because the condition on line 2114 was always true
2115 return _EMPTY_TOOLSET_GRANTS
2116 resolved: Final = await global_mcp_server_manager.resolve_toolset_tool_permissions(
2117 toolset_ids=object_permission.mcp_toolsets
2118 )
2119 if not resolved:
2120 raise UnloadableEntitlementError(
2121 f"declared mcp_toolsets {object_permission.mcp_toolsets!r} resolved to no grants"
2122 )
2123 return resolved
2125 @staticmethod
2126 async def _toolset_tools_for_server(
2127 object_permission: LiteLLM_ObjectPermissionTable | None,
2128 server_id: str,
2129 ) -> Sequence[str] | None:
2130 """Tool names this row's toolsets grant on ``server_id``, ``None`` when its toolsets place
2131 no restriction on that server (it declares no toolsets, or none of them name it)."""
2132 return (await MCPRequestHandler._toolset_tool_permissions(object_permission)).get(server_id)
2134 @staticmethod
2135 def _union_tool_grants(
2136 direct: Sequence[str] | None,
2137 via_toolsets: Sequence[str] | None,
2138 ) -> Sequence[str] | None:
2139 """Union of one level's direct tool grants and its toolset-granted tools on one server,
2140 ``None`` when neither source restricts (allow-all from this level). A direct grant
2141 containing ``MCP_ALL_TOOLS_WILDCARD`` makes the level unrestricted, so it returns
2142 ``None`` whatever the toolsets name."""
2143 if direct is not None and MCP_ALL_TOOLS_WILDCARD in direct: 2143 ↛ 2144line 2143 didn't jump to line 2144 because the condition on line 2143 was never true
2144 return None
2145 if direct is None and via_toolsets is None: 2145 ↛ 2147line 2145 didn't jump to line 2147 because the condition on line 2145 was always true
2146 return None
2147 return tuple({*(direct or ()), *(via_toolsets or ())})
2149 @staticmethod
2150 async def _key_object_permission_hydrated(
2151 user_api_key_auth: UserAPIKeyAuth,
2152 ) -> LiteLLM_ObjectPermissionTable | None:
2153 """The key's object_permission, loading it by ``object_permission_id`` when the main auth
2154 flow cached the key with the relation unhydrated (its loader swallows a failed read and
2155 caches the partial object)."""
2156 loaded: Final = MCPRequestHandler._get_key_object_permission(user_api_key_auth)
2157 if loaded is not None or not user_api_key_auth.object_permission_id: 2157 ↛ 2159line 2157 didn't jump to line 2159 because the condition on line 2157 was always true
2158 return loaded
2159 from litellm.proxy.auth.auth_checks import get_object_permission
2160 from litellm.proxy.proxy_server import (
2161 prisma_client,
2162 proxy_logging_obj,
2163 user_api_key_cache,
2164 )
2166 if prisma_client is None:
2167 return None
2168 return await get_object_permission(
2169 object_permission_id=user_api_key_auth.object_permission_id,
2170 prisma_client=prisma_client,
2171 user_api_key_cache=user_api_key_cache,
2172 parent_otel_span=user_api_key_auth.parent_otel_span,
2173 proxy_logging_obj=proxy_logging_obj,
2174 )
2176 @staticmethod
2177 async def _key_or_team_declares_toolsets(user_api_key_auth: UserAPIKeyAuth | None) -> bool:
2178 """Whether the key or its team GRANTS any toolset, resolvable or not. A declared toolset is
2179 a lower-level restriction even when it resolves to no servers (deleted or unknown ids), so the
2180 org ceiling may only cap it; reading an empty resolution as "no restriction" would substitute
2181 the org's entire server list for the narrowest grant an operator can write.
2183 Falls back to the DB when the auth object carries ``object_permission_id`` unhydrated (the
2184 main auth flow swallows a failed load and caches the partial object). An INDETERMINATE fault
2185 answers False — no gate, org substitution as before the fault — mirroring how the org ceiling
2186 keeps key auth open on a fault it cannot classify."""
2187 if user_api_key_auth is None: 2187 ↛ 2188line 2187 didn't jump to line 2188 because the condition on line 2187 was never true
2188 return False
2189 try:
2190 key_obj_perm: Final = await MCPRequestHandler._key_object_permission_hydrated(user_api_key_auth)
2191 if key_obj_perm is not None and key_obj_perm.mcp_toolsets: 2191 ↛ 2192line 2191 didn't jump to line 2192 because the condition on line 2191 was never true
2192 return True
2193 if not user_api_key_auth.team_id: 2193 ↛ 2195line 2193 didn't jump to line 2195 because the condition on line 2193 was always true
2194 return False
2195 team_obj_perm: Final = await MCPRequestHandler._get_team_object_permission(user_api_key_auth)
2196 return bool(team_obj_perm is not None and team_obj_perm.mcp_toolsets)
2197 except Exception as e: # noqa: BLE001 # indeterminate fault: no gate, as before this level existed
2198 verbose_logger.warning("Failed to check declared MCP toolsets, org ceiling unchanged: %s", e)
2199 return False
2201 @staticmethod
2202 async def get_allowed_tools_for_server(
2203 server_id: str,
2204 user_api_key_auth: UserAPIKeyAuth | None = None,
2205 *,
2206 keyless_source: bool = False,
2207 ) -> list[str] | None:
2208 """
2209 Get list of allowed tool names for a specific server based on key/team permissions.
2210 Follows same inheritance logic as get_allowed_mcp_servers.
2212 Args:
2213 server_id: Server ID to check permissions for
2214 user_api_key_auth: User auth
2216 Returns:
2217 List[str] if restrictions exist, None if no restrictions (allow all)
2218 """
2219 if not user_api_key_auth: 2219 ↛ 2220line 2219 didn't jump to line 2220 because the condition on line 2219 was never true
2220 return None
2222 try:
2223 # FIRST statement, mirroring get_allowed_mcp_servers: a keyless admitted subject resolves per
2224 # source and shares nothing with the single-credential prelude below. Ordering is the invariant:
2225 # sat after the prelude, a fault in a lookup the subject never uses denied tools its teams grant.
2226 if _is_mcp_admitted_user_subject(user_api_key_auth): 2226 ↛ 2227line 2226 didn't jump to line 2227 because the condition on line 2226 was never true
2227 return await MCPRequestHandler._resolve_admitted_subject_tools(server_id, user_api_key_auth)
2229 # Get key and team object permissions (already loaded in main auth flow)
2230 key_obj_perm: Final = MCPRequestHandler._get_key_object_permission(user_api_key_auth)
2231 team_obj_perm: Final = await MCPRequestHandler._get_team_object_permission(user_api_key_auth)
2233 # Extract tool permissions for this server. Dict keys may be
2234 # server_ids OR names/aliases; normalize to server_id-keyed form
2235 # before lookup so a name-based key does not silently drop its
2236 # tool restrictions when server_id is the resolved uuid.
2237 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2238 global_mcp_server_manager,
2239 )
2241 key_direct_tools: Final = (
2242 global_mcp_server_manager.expand_tool_permissions(key_obj_perm.mcp_tool_permissions).get(server_id)
2243 if key_obj_perm
2244 else None
2245 )
2247 # Tools granted through the key's toolsets restrict this server exactly
2248 # as direct tool permissions do; union with any direct grants so the
2249 # tool-level check sees the key's full effective tool scope
2250 key_toolset_ids: Final = (key_obj_perm.mcp_toolsets or []) if key_obj_perm else []
2251 key_toolset_tools: Final = (
2252 (await global_mcp_server_manager.resolve_toolset_tool_permissions(toolset_ids=key_toolset_ids)).get(
2253 server_id
2254 )
2255 if key_toolset_ids
2256 else None
2257 )
2259 key_tools: Final = _as_list(MCPRequestHandler._union_tool_grants(key_direct_tools, key_toolset_tools))
2260 team_direct_tools: Final = (
2261 global_mcp_server_manager.expand_tool_permissions(team_obj_perm.mcp_tool_permissions).get(server_id)
2262 if team_obj_perm
2263 else None
2264 )
2266 # Tools granted through the team's toolsets restrict this server exactly
2267 # as the team's direct tool permissions do, mirroring the key path above
2268 team_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(team_obj_perm, server_id)
2269 team_tools: Final = MCPRequestHandler._union_tool_grants(team_direct_tools, team_toolset_tools)
2271 # Apply same inheritance logic as get_allowed_mcp_servers
2272 if team_tools: 2272 ↛ 2273line 2272 didn't jump to line 2273 because the condition on line 2272 was never true
2273 if key_tools:
2274 # Both have restrictions → intersection
2275 allowed_tools = list(set(team_tools) & set(key_tools))
2276 else:
2277 # Only team has restrictions → inherit from team
2278 allowed_tools = team_tools
2279 else:
2280 # No team restrictions → use key restrictions
2281 allowed_tools = cast(list[str], key_tools)
2283 allowed_tools = _as_list(
2284 await MCPRequestHandler._apply_end_user_tool_ceiling(allowed_tools, server_id, user_api_key_auth)
2285 )
2287 allowed_tools = _as_list(
2288 await MCPRequestHandler._apply_user_tool_ceiling(
2289 allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source
2290 )
2291 )
2293 allowed_tools = _as_list(
2294 await MCPRequestHandler._apply_agent_caller_tool_ceiling(allowed_tools, server_id, user_api_key_auth)
2295 )
2297 return await MCPRequestHandler._apply_agent_and_org_tool_ceilings(
2298 allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source
2299 )
2301 except Exception as e:
2302 # An entitlement known to exist but unreadable denies for BOTH caller shapes, so [] rather
2303 # than the None (allow-all) key auth gets for an indeterminate fault.
2304 unreadable_entitlement: Final = isinstance(e, UnloadableEntitlementError)
2305 if unreadable_entitlement:
2306 verbose_logger.warning("Denying MCP tools, entitlement unreadable: %s", e)
2307 else:
2308 verbose_logger.warning("Failed to get allowed tools for server: %s", e)
2309 # Fail CLOSED for a keyless admitted subject: ANY error must deny the server's tools ([]),
2310 # not collapse to allow-all (None); key/JWT auth keeps its prior allow-all-on-error. Both
2311 # keyless_source AND the marker are needed: each source resolves through an UNMARKED auth, so
2312 # without keyless_source a fault under a source returns None and wins the union as allow-all.
2313 deny_all = unreadable_entitlement or keyless_source or _is_mcp_admitted_user_subject(user_api_key_auth)
2314 return [] if deny_all else None
2316 @staticmethod
2317 async def _apply_agent_and_org_tool_ceilings(
2318 allowed_tools: list[str] | None,
2319 server_id: str,
2320 user_api_key_auth: UserAPIKeyAuth,
2321 keyless_source: bool = False,
2322 ) -> list[str] | None:
2323 """Narrow a key/team tool allowlist by the agent's tool permissions and the caller's org tool
2324 ceiling. Each level only intersects; None at a level means no restriction from it.
2326 An UNRESOLVABLE org ceiling is decided per caller shape, mirroring the servers axis: a key stays
2327 fail-open (skip the org step, keep the key/team/agent restrictions; letting the raise escape
2328 would collapse them to allow-all, WIDER than before the fault), while a keyless source re-raises
2329 so the outer handler denies that one source (its only org bound is this ceiling)."""
2330 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2331 global_mcp_server_manager,
2332 )
2334 if user_api_key_auth.agent_id: 2334 ↛ 2336line 2334 didn't jump to line 2336 because the condition on line 2334 was never true
2335 # Pre-fetch agent object_permission once to avoid a duplicate DB query.
2336 agent_obj_perm: Final = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth)
2337 agent_tools: Final = await MCPRequestHandler._get_agent_tool_permissions_for_server(
2338 server_id=server_id,
2339 user_api_key_auth=user_api_key_auth,
2340 agent_object_permission=agent_obj_perm,
2341 )
2342 if agent_tools is not None:
2343 allowed_tools = (
2344 list(set(allowed_tools) & set(agent_tools)) if allowed_tools is not None else agent_tools
2345 )
2347 if user_api_key_auth.org_id: 2347 ↛ 2350line 2347 didn't jump to line 2350 because the condition on line 2347 was never true
2348 # _get_org_object_permission uses user_api_key_cache, so this is not a fresh DB round-trip
2349 # when get_allowed_mcp_servers was already called.
2350 try:
2351 org_obj_perm: Final = await MCPRequestHandler._get_org_object_permission(user_api_key_auth)
2352 except Exception as e: # noqa: BLE001 # unresolvable org ceiling, decided per caller shape
2353 # A ceiling the org NAMES but that cannot be read denies at every caller shape; only an
2354 # INDETERMINATE fault (we cannot tell whether a ceiling exists) keeps key auth open.
2355 if keyless_source or isinstance(e, UnloadableEntitlementError):
2356 raise
2357 verbose_logger.warning(
2358 "MCP org tool ceiling unresolvable for org_id=%r; skipping org intersect, key/team/agent restrictions stand: %s",
2359 user_api_key_auth.org_id,
2360 e,
2361 )
2362 return allowed_tools
2363 org_direct_tools: Final = (
2364 global_mcp_server_manager.expand_tool_permissions(org_obj_perm.mcp_tool_permissions).get(server_id)
2365 if org_obj_perm and org_obj_perm.mcp_tool_permissions
2366 else None
2367 )
2368 org_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(org_obj_perm, server_id)
2369 org_tools: Final = MCPRequestHandler._union_tool_grants(org_direct_tools, org_toolset_tools)
2370 if org_tools is not None:
2371 allowed_tools = (
2372 list(set(allowed_tools) & set(org_tools)) if allowed_tools is not None else list(org_tools)
2373 )
2375 return allowed_tools
2377 @staticmethod
2378 def tool_is_granted(bare_tool_name: str, allowed_tool_names: list[str] | None) -> bool:
2379 """Whether key/team tool permissions reach ``bare_tool_name`` on one server.
2381 ``None`` means no tool-level restriction; an empty list grants nothing. Entries
2382 name a tool on a single server and every writer stores them bare, so the
2383 comparison is exact against the bare name rather than against the spellings
2384 routing accepts. Both the listing path and the call path answer through here, so
2385 discovery cannot advertise a tool that ``tools/call`` then refuses.
2386 """
2387 return allowed_tool_names is None or bare_tool_name in allowed_tool_names
2389 @staticmethod
2390 async def is_tool_allowed_for_server(
2391 tool_name: str,
2392 server_id: str,
2393 user_api_key_auth: UserAPIKeyAuth | None = None,
2394 ) -> bool:
2395 """
2396 Check if a specific tool is allowed for a server based on key/team permissions.
2398 Args:
2399 tool_name: Bare tool name, already resolved against the server's prefixes
2400 server_id: Server ID
2401 user_api_key_auth: User auth
2403 Returns:
2404 True if allowed, False if blocked
2405 """
2406 allowed_tools: Final = await MCPRequestHandler.get_allowed_tools_for_server(
2407 server_id=server_id,
2408 user_api_key_auth=user_api_key_auth,
2409 )
2410 return MCPRequestHandler.tool_is_granted(tool_name, allowed_tools)
2412 @staticmethod
2413 def is_tool_allowed(
2414 allowed_mcp_servers: list[str],
2415 server_name: str,
2416 ) -> bool:
2417 """
2418 Check if the tool is allowed for the given user/key based on permissions
2419 """
2420 if len(allowed_mcp_servers) == 0:
2421 return False
2422 elif server_name in allowed_mcp_servers:
2423 return True
2424 return False
2426 @staticmethod
2427 async def _get_key_access_group_mcp_server_extras(
2428 user_api_key_auth: UserAPIKeyAuth | None = None,
2429 ) -> list[str]:
2430 """
2431 Resolve the key's unified `access_group_ids` (LiteLLM_AccessGroupTable) to
2432 MCP server IDs as additive grants: a group attached to the key extends the
2433 key's allowed servers on top of the key/team ceiling rather than being
2434 capped by the team. Attaching the group to the key is itself the grant —
2435 no `assigned_key_ids` / `assigned_team_ids` re-check. Tag-style
2436 `mcp_access_groups` (per-server tags) live in the key's object_permission
2437 scope, not here.
2438 """
2439 if user_api_key_auth is None: 2439 ↛ 2440line 2439 didn't jump to line 2440 because the condition on line 2439 was never true
2440 return []
2441 try:
2442 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2443 global_mcp_server_manager,
2444 )
2445 from litellm.proxy.auth.auth_checks import (
2446 _get_mcp_server_ids_from_access_groups,
2447 )
2448 from litellm.proxy.proxy_server import (
2449 prisma_client,
2450 proxy_logging_obj,
2451 user_api_key_cache,
2452 )
2454 raw_server_ids: Final = await _get_mcp_server_ids_from_access_groups(
2455 access_group_ids=user_api_key_auth.access_group_ids or [],
2456 prisma_client=prisma_client,
2457 user_api_key_cache=user_api_key_cache,
2458 proxy_logging_obj=proxy_logging_obj,
2459 )
2460 if not raw_server_ids: 2460 ↛ 2463line 2460 didn't jump to line 2463 because the condition on line 2460 was always true
2461 return []
2462 # Permission entries may be server_ids OR names/aliases — expand to ids.
2463 return global_mcp_server_manager.expand_permission_list(raw_server_ids)
2464 except Exception as e:
2465 verbose_logger.warning("Failed to get key access group MCP server grants: %s", e)
2466 return []
2468 @staticmethod
2469 async def _get_allowed_mcp_servers_for_key(
2470 user_api_key_auth: UserAPIKeyAuth | None = None,
2471 ) -> list[str]:
2472 """
2473 Get the key's own MCP ceiling from its object_permission
2474 (mcp_servers, tag-style mcp_access_groups, mcp_tool_permissions).
2476 Unified key.access_group_ids are NOT resolved here — they are additive
2477 grants handled by _get_key_access_group_mcp_server_extras and unioned on
2478 top of the key/team ceiling, so they must not enter this scope (which is
2479 intersected against the team).
2480 """
2481 if user_api_key_auth is None: 2481 ↛ 2482line 2481 didn't jump to line 2482 because the condition on line 2481 was never true
2482 return []
2483 try:
2484 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2485 global_mcp_server_manager,
2486 )
2487 from litellm.proxy.auth.auth_checks import (
2488 get_object_permission,
2489 )
2490 from litellm.proxy.proxy_server import (
2491 prisma_client,
2492 proxy_logging_obj,
2493 user_api_key_cache,
2494 )
2496 # Get key object permission (already loaded in main auth flow, or fetch from DB)
2497 key_object_permission = MCPRequestHandler._get_key_object_permission(user_api_key_auth)
2498 if key_object_permission is None and user_api_key_auth.object_permission_id and prisma_client is not None: 2498 ↛ 2499line 2498 didn't jump to line 2499 because the condition on line 2498 was never true
2499 key_object_permission = await get_object_permission(
2500 object_permission_id=user_api_key_auth.object_permission_id,
2501 prisma_client=prisma_client,
2502 user_api_key_cache=user_api_key_cache,
2503 parent_otel_span=user_api_key_auth.parent_otel_span,
2504 proxy_logging_obj=proxy_logging_obj,
2505 )
2506 if key_object_permission is None: 2506 ↛ 2507line 2506 didn't jump to line 2507 because the condition on line 2506 was never true
2507 return []
2509 # Sentinel opt-out: surface it unexpanded so the caller can short-circuit
2510 # to zero servers instead of inheriting the team.
2511 if SpecialMCPServerNames.no_mcp_servers.value in (key_object_permission.mcp_servers or []): 2511 ↛ 2512line 2511 didn't jump to line 2512 because the condition on line 2511 was never true
2512 return [SpecialMCPServerNames.no_mcp_servers.value]
2514 # Permission entries may be server_ids OR names/aliases — expand to ids.
2515 direct_mcp_servers: Final = global_mcp_server_manager.expand_permission_list(
2516 key_object_permission.mcp_servers or []
2517 )
2519 # Get MCP servers from access groups
2520 access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups(
2521 key_object_permission.mcp_access_groups or []
2522 )
2524 # servers referenced in tool permissions should also be accessible
2525 tool_perm_servers: Final = list(
2526 global_mcp_server_manager.expand_tool_permissions(key_object_permission.mcp_tool_permissions).keys()
2527 )
2529 # servers referenced by the key's toolset grants are part of the key's
2530 # scope on every path (list, call, REST), subject to the same team/org
2531 # ceilings as any other key-level grant
2532 toolset_ids: Final = key_object_permission.mcp_toolsets or []
2533 toolset_servers: Final = (
2534 list((await global_mcp_server_manager.resolve_toolset_tool_permissions(toolset_ids=toolset_ids)).keys())
2535 if toolset_ids
2536 else []
2537 )
2539 # Combine all lists
2540 all_servers: Final = direct_mcp_servers + access_group_servers + tool_perm_servers + toolset_servers
2541 return list(set(all_servers))
2542 except Exception as e:
2543 verbose_logger.warning("Failed to get allowed MCP servers for key: %s", e)
2544 return []
2546 @staticmethod
2547 async def _get_allowed_mcp_servers_for_team(
2548 user_api_key_auth: UserAPIKeyAuth | None = None,
2549 ) -> list[str]:
2550 """Get allowed MCP servers a caller inherits from the team it is pinned to.
2552 Exactly one team, or none. A subject that reaches servers through SEVERAL teams does not
2553 fan out here: it is resolved one source per team in ``_resolve_admitted_subject_servers``,
2554 and each of those sources pins a single ``team_id`` before reaching this point. Keeping the
2555 fan-out here as well would be a second multi-team path to drift from that one.
2556 """
2557 team_ids: Final = await MCPRequestHandler._team_ids_for_mcp_grant(user_api_key_auth)
2558 if not team_ids: 2558 ↛ 2560line 2558 didn't jump to line 2560 because the condition on line 2558 was always true
2559 return []
2560 return await MCPRequestHandler._allowed_mcp_servers_for_single_team(team_ids[0], user_api_key_auth)
2562 @staticmethod
2563 async def _team_ids_for_mcp_grant(user_api_key_auth: UserAPIKeyAuth | None) -> list[str]:
2564 """The team ids whose MCP grants a caller inherits.
2566 A caller with an explicit ``team_id`` uses that single team; every other caller inherits no
2567 team grants. That covers key auth and JWT auth (a keyless ``user_id`` auth with no team_id,
2568 which must NOT silently gain the union across every team the user belongs to), and it covers
2569 each single-source auth an admitted subject fans out into — those pin a team_id, so they land
2570 on the first branch. The admitted subject itself never reaches here: it resolves per source
2571 in ``_resolve_admitted_subject_servers`` before this point. The ``UI_TEAM_ID`` sentinel
2572 resolves to no teams exactly as before."""
2573 if user_api_key_auth is None or not user_api_key_auth.team_id: 2573 ↛ 2575line 2573 didn't jump to line 2575 because the condition on line 2573 was always true
2574 return []
2575 return [] if user_api_key_auth.team_id == UI_TEAM_ID else [user_api_key_auth.team_id]
2577 @staticmethod
2578 async def _resolve_user_team_ids(user_id: str, user_api_key_auth: UserAPIKeyAuth) -> list[str]:
2579 """The distinct team ids a user belongs to, from the live user record. Returns [] on
2580 no DB, a missing user, or any resolution failure so a lookup blip narrows access
2581 rather than raising; the caller's direct grants still apply."""
2582 from litellm.proxy.auth.auth_checks import get_user_object
2583 from litellm.proxy.proxy_server import (
2584 prisma_client,
2585 proxy_logging_obj,
2586 user_api_key_cache,
2587 )
2589 if prisma_client is None:
2590 return []
2591 try:
2592 user_object: Final = await get_user_object(
2593 user_id=user_id,
2594 prisma_client=prisma_client,
2595 user_api_key_cache=user_api_key_cache,
2596 user_id_upsert=False,
2597 parent_otel_span=user_api_key_auth.parent_otel_span,
2598 proxy_logging_obj=proxy_logging_obj,
2599 )
2600 except Exception as e: # noqa: BLE001 # a team-resolution blip narrows access, never raises
2601 verbose_logger.warning("Failed to resolve user teams for MCP grant: %s", e)
2602 return []
2603 if user_object is None or not user_object.teams:
2604 return []
2605 return list(dict.fromkeys(t for t in user_object.teams if t and t != UI_TEAM_ID))
2607 @staticmethod
2608 async def _team_granted_servers(team_obj: LiteLLM_TeamTable, team_access_group_servers: list[str]) -> set[str]:
2609 """The raw MCP-server set a team grants (before any org ceiling): its object_permission (direct
2610 ``mcp_servers``, the ``all_proxy_servers`` sentinel → the full registry, legacy access groups,
2611 tool-perm-referenced servers, toolset-referenced servers) unioned with its unified
2612 ``access_group_ids`` servers."""
2613 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2614 global_mcp_server_manager,
2615 )
2617 object_permissions: Final = team_obj.object_permission
2618 if object_permissions is None:
2619 return set(team_access_group_servers)
2620 if SpecialMCPServerName.all_proxy_servers.value in (object_permissions.mcp_servers or []):
2621 return set(global_mcp_server_manager.get_registry().keys())
2622 legacy_access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups(
2623 object_permissions.mcp_access_groups or []
2624 )
2625 return (
2626 set(global_mcp_server_manager.expand_permission_list(object_permissions.mcp_servers or []))
2627 | set(legacy_access_group_servers)
2628 | set(global_mcp_server_manager.expand_tool_permissions(object_permissions.mcp_tool_permissions).keys())
2629 | (await MCPRequestHandler._toolset_tool_permissions(object_permissions)).keys()
2630 | set(team_access_group_servers)
2631 )
2633 @staticmethod
2634 async def _allowed_mcp_servers_for_single_team(
2635 team_id: str,
2636 user_api_key_auth: UserAPIKeyAuth | None,
2637 ) -> list[str]:
2638 """Allowed MCP servers granted by ONE team (its raw grant, then capped by the team's own org
2639 for a keyless admitted subject).
2641 Unions two sources:
2642 - Legacy team.object_permission (mcp_servers, mcp_access_groups,
2643 mcp_tool_permissions).
2644 - Unified team.access_group_ids → access_group.access_mcp_server_ids.
2645 Mirrors the model-side pattern in can_team_access_model — the group
2646 is already attached to the team, so the team relationship is itself
2647 the gate (no assigned_team_ids check needed here).
2648 """
2649 try:
2650 from litellm.proxy.auth.auth_checks import (
2651 _get_mcp_server_ids_from_access_groups,
2652 get_team_object,
2653 )
2654 from litellm.proxy.proxy_server import (
2655 prisma_client,
2656 proxy_logging_obj,
2657 user_api_key_cache,
2658 )
2660 if not team_id or team_id == UI_TEAM_ID or prisma_client is None:
2661 return []
2663 parent_otel_span: Final = user_api_key_auth.parent_otel_span if user_api_key_auth is not None else None
2664 team_obj: Final[LiteLLM_TeamTable | None] = await get_team_object(
2665 team_id=team_id,
2666 prisma_client=prisma_client,
2667 user_api_key_cache=user_api_key_cache,
2668 parent_otel_span=parent_otel_span,
2669 proxy_logging_obj=proxy_logging_obj,
2670 )
2671 if team_obj is None:
2672 return []
2673 if team_obj.blocked:
2674 # A blocked team grants nothing. The central policy gate enforces this for a key
2675 # pinned to a single team_id, but a keyless admitted identity (no team_id) unions
2676 # across all of its teams and would otherwise inherit a blocked team's MCP grants.
2677 return []
2678 team_access_group_servers: Final = await _get_mcp_server_ids_from_access_groups(
2679 access_group_ids=team_obj.access_group_ids or [],
2680 prisma_client=prisma_client,
2681 user_api_key_cache=user_api_key_cache,
2682 proxy_logging_obj=proxy_logging_obj,
2683 )
2685 servers: Final = await MCPRequestHandler._team_granted_servers(team_obj, team_access_group_servers)
2686 return list(servers)
2687 except Exception as e:
2688 if isinstance(e, UnloadableEntitlementError):
2689 raise
2690 verbose_logger.warning("Failed to get allowed MCP servers for team: %s", e)
2691 return []
2693 @staticmethod
2694 async def _load_named_object_permission(
2695 principal: str,
2696 object_permission_id: str,
2697 prisma_client: "PrismaClient",
2698 user_api_key_auth: UserAPIKeyAuth,
2699 ) -> LiteLLM_ObjectPermissionTable:
2700 """Load the object permission a principal's row NAMES, or raise ``UnloadableEntitlementError``.
2702 The single place that fault is minted, so end user, agent and org cannot drift on what counts
2703 as "known entitlement, unknown contents". ``get_object_permission`` answers None for both an
2704 absent row and a failed read, and neither is evidence the principal is unrestricted: the link
2705 proves an entitlement was configured, so both must deny."""
2706 from litellm.proxy.auth.auth_checks import get_object_permission
2707 from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
2709 unloadable: Final = UnloadableEntitlementError(
2710 f"{principal} names object_permission_id {object_permission_id!r} which could not be loaded"
2711 )
2712 try:
2713 object_permission: Final = await get_object_permission(
2714 object_permission_id=object_permission_id,
2715 prisma_client=prisma_client,
2716 user_api_key_cache=user_api_key_cache,
2717 parent_otel_span=user_api_key_auth.parent_otel_span,
2718 proxy_logging_obj=proxy_logging_obj,
2719 )
2720 except Exception as e: # noqa: BLE001 # a named entitlement we cannot read denies, whatever the read failed with
2721 raise unloadable from e
2722 if object_permission is None:
2723 raise unloadable
2724 return object_permission
2726 @staticmethod
2727 async def _get_org_object_permission(
2728 user_api_key_auth: UserAPIKeyAuth | None = None,
2729 ) -> LiteLLM_ObjectPermissionTable | None:
2730 """
2731 Get org object_permission via the established ``get_org_object`` /
2732 ``get_object_permission`` helpers so MCP requests share the same
2733 ``user_api_key_cache`` entries as the rest of the proxy.
2735 ``None`` means the org places NO ceiling: no ``org_id``, no DB, or an org row naming no
2736 permission. A row that NAMES one it cannot load raises ``UnloadableEntitlementError``;
2737 every other lookup failure propagates as itself, leaving the ceiling merely unresolved.
2738 """
2739 from litellm.proxy.auth.auth_checks import (
2740 OrganizationNotFoundError,
2741 get_org_object,
2742 )
2743 from litellm.proxy.proxy_server import (
2744 prisma_client,
2745 proxy_logging_obj,
2746 user_api_key_cache,
2747 )
2749 if not user_api_key_auth or not user_api_key_auth.org_id:
2750 return None
2752 if prisma_client is None:
2753 verbose_logger.debug("prisma_client is None")
2754 return None
2756 # A team's organization_id can point at a deleted or not-yet-synced row; get_org_object raises
2757 # OrganizationNotFoundError for that. That is a determinate ABSENCE (no ceiling), handled below.
2758 try:
2759 org_obj: Final = await get_org_object(
2760 org_id=user_api_key_auth.org_id,
2761 prisma_client=prisma_client,
2762 user_api_key_cache=user_api_key_cache,
2763 parent_otel_span=user_api_key_auth.parent_otel_span,
2764 proxy_logging_obj=proxy_logging_obj,
2765 )
2766 except OrganizationNotFoundError as e:
2767 # CONFIRMED absent: places no ceiling. Every OTHER exception propagates as an unresolvable
2768 # ceiling (denies for a keyless source, fail-open for a key); catching bare Exception here
2769 # would treat a DB outage as "no org" and silently drop a real ceiling for its duration.
2770 verbose_logger.debug("MCP org ceiling: org %r does not exist: %s", user_api_key_auth.org_id, e)
2771 return None
2773 if org_obj is None or not org_obj.object_permission_id:
2774 return None
2776 # The org NAMES a permission; failing to read it is a KNOWN ceiling with unknown contents and
2777 # must not collapse into the None that means "no ceiling". Raising denies at every caller shape.
2778 return await MCPRequestHandler._load_named_object_permission(
2779 principal=f"org {user_api_key_auth.org_id!r}",
2780 object_permission_id=org_obj.object_permission_id,
2781 prisma_client=prisma_client,
2782 user_api_key_auth=user_api_key_auth,
2783 )
2785 @staticmethod
2786 async def _get_allowed_mcp_servers_for_org(
2787 user_api_key_auth: UserAPIKeyAuth | None = None,
2788 ) -> list[str] | None:
2789 """
2790 Get allowed MCP servers for an organization.
2792 Returns the MCP servers from the org's object_permission.
2793 An empty result means the org places no restriction (allow-all from this level), ``None``
2794 that the ceiling could not be resolved, which the caller decides per shape.
2796 A ceiling the org NAMES but we cannot read is neither: it raises out of here so both caller
2797 shapes deny, because dropping a ceiling known to exist is exactly the silent widening the
2798 level is there to prevent.
2799 """
2800 try:
2801 object_permissions: Final = await MCPRequestHandler._get_org_object_permission(user_api_key_auth)
2803 if object_permissions is None:
2804 return []
2806 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2807 global_mcp_server_manager,
2808 )
2810 # Expand names/aliases to canonical server IDs (consistent with key/team/end-user path)
2811 direct_mcp_servers = global_mcp_server_manager.expand_permission_list(object_permissions.mcp_servers or [])
2813 access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups(
2814 object_permissions.mcp_access_groups or []
2815 )
2817 tool_perm_servers: Final = list(
2818 global_mcp_server_manager.expand_tool_permissions(object_permissions.mcp_tool_permissions).keys()
2819 )
2821 # servers referenced by the org's toolset grants are part of the org ceiling,
2822 # exactly as servers referenced by its inline tool permissions are
2823 toolset_grants: Final = await MCPRequestHandler._toolset_tool_permissions(object_permissions)
2825 all_servers: Final = tuple(
2826 {*direct_mcp_servers, *access_group_servers, *tool_perm_servers, *toolset_grants}
2827 )
2828 return list(set(all_servers))
2829 except Exception as e:
2830 # None = ceiling UNRESOLVED, distinct from [] = org places no restriction. Collapsing them
2831 # let a DB fault silently drop a ceiling; the caller picks fail-open/closed from this signal.
2832 # A NAMED-but-unreadable ceiling is a stronger fact than "unresolved" and denies everywhere.
2833 if isinstance(e, UnloadableEntitlementError):
2834 raise
2835 verbose_logger.warning("Failed to get allowed MCP servers for org: %s", e)
2836 return None
2838 @staticmethod
2839 async def _get_end_user_object_permission(
2840 user_api_key_auth: UserAPIKeyAuth,
2841 prisma_client: "PrismaClient",
2842 ) -> LiteLLM_ObjectPermissionTable | None:
2843 """The end user's own object_permission, or ``None`` when this level places no restriction.
2845 ``None`` covers an end user row that is absent or names no permission, and an end user we
2846 could not resolve at all (``get_end_user_object`` answers None for an absent row AND for a
2847 failed read, so this level genuinely cannot tell those apart). A row that DOES name a
2848 permission we cannot load raises ``UnloadableEntitlementError``: the link is positive
2849 evidence of an entitlement, so its contents may not be assumed empty."""
2850 from litellm.proxy.auth.auth_checks import get_end_user_object
2851 from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
2853 try:
2854 end_user_obj: Final = await get_end_user_object(
2855 end_user_id=user_api_key_auth.end_user_id,
2856 prisma_client=prisma_client,
2857 user_api_key_cache=user_api_key_cache,
2858 parent_otel_span=user_api_key_auth.parent_otel_span,
2859 proxy_logging_obj=proxy_logging_obj,
2860 route="/mcp",
2861 )
2862 except Exception as e: # noqa: BLE001 # entitlement unknown, not known-absent: no ceiling, as before this level
2863 verbose_logger.warning("Failed to resolve end_user for MCP permissions: %s", e)
2864 return None
2866 if end_user_obj is None:
2867 return None
2868 if end_user_obj.object_permission is not None:
2869 return end_user_obj.object_permission
2870 if not end_user_obj.object_permission_id:
2871 return None
2872 # The row NAMES a permission the relation did not carry. One shared (cached) lookup decides
2873 # whether it is readable; an unreadable one denies rather than reading as "no restriction".
2874 return await MCPRequestHandler._load_named_object_permission(
2875 principal=f"end user {user_api_key_auth.end_user_id!r}",
2876 object_permission_id=end_user_obj.object_permission_id,
2877 prisma_client=prisma_client,
2878 user_api_key_auth=user_api_key_auth,
2879 )
2881 @staticmethod
2882 async def _get_allowed_mcp_servers_for_end_user(
2883 user_api_key_auth: UserAPIKeyAuth | None = None,
2884 ) -> list[str]:
2885 """
2886 Get allowed MCP servers for an end user.
2888 Returns the MCP servers from the end_user's object_permission; an empty result means this
2889 level places no restriction. An entitlement the end user row NAMES but that cannot be read
2890 raises ``UnloadableEntitlementError`` out of here so the resolver denies.
2891 """
2892 from litellm.proxy.proxy_server import prisma_client
2894 if not user_api_key_auth or not user_api_key_auth.end_user_id:
2895 return []
2897 if prisma_client is None:
2898 verbose_logger.debug("prisma_client is None")
2899 return []
2901 object_permission = await MCPRequestHandler._get_end_user_object_permission(user_api_key_auth, prisma_client)
2902 if object_permission is None:
2903 return []
2905 try:
2906 # Permission entries may be server_ids OR names/aliases — expand to ids.
2907 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
2908 global_mcp_server_manager,
2909 )
2911 direct_mcp_servers = global_mcp_server_manager.expand_permission_list(object_permission.mcp_servers or [])
2913 # Get MCP servers from access groups
2914 access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups(
2915 object_permission.mcp_access_groups or []
2916 )
2918 # servers referenced in tool permissions should also be accessible
2919 tool_perm_servers: Final = list(
2920 global_mcp_server_manager.expand_tool_permissions(object_permission.mcp_tool_permissions).keys()
2921 )
2923 # Combine all lists
2924 all_servers: Final = direct_mcp_servers + access_group_servers + tool_perm_servers
2925 return list(set(all_servers))
2926 except Exception as e:
2927 verbose_logger.warning("Failed to get allowed MCP servers for end_user: %s", e)
2928 return []
2930 @staticmethod
2931 async def _get_user_object_permission(
2932 user_api_key_auth: UserAPIKeyAuth | None = None,
2933 ) -> LiteLLM_ObjectPermissionTable | None:
2934 """The internal user's OWN object_permission: the entitlement attached to the HUMAN rather
2935 than to the credential they authenticated with.
2937 A key's object_permission is the credential's scope and a team's is the group's; this one
2938 answers "which MCP servers and tools is this person entitled to", independent of how many keys
2939 they hold. Caches the ``user_id -> object_permission_id`` mapping (with a sentinel for "no
2940 entitlement") exactly as the agent path does, then reuses the shared ``object_permission_id``
2941 cache, so a warm request reads no rows.
2943 ``None`` means the human places NO ceiling: no user row, or a row naming no permission. The
2944 two fault classes are deliberately NOT collapsed into that: a user row we cannot read leaves
2945 us unable to say whether they are entitled at all, which is exactly the state before this
2946 level existed, so it places no ceiling; a row that NAMES a permission we cannot read is a
2947 KNOWN entitlement with unknown contents, so it raises and the caller denies.
2948 """
2949 from litellm.proxy.auth.auth_checks import get_object_permission
2950 from litellm.proxy.proxy_server import (
2951 prisma_client,
2952 proxy_logging_obj,
2953 user_api_key_cache,
2954 )
2956 if not user_api_key_auth or not user_api_key_auth.user_id: 2956 ↛ 2957line 2956 didn't jump to line 2957 because the condition on line 2956 was never true
2957 return None
2959 if prisma_client is None: 2959 ↛ 2960line 2959 didn't jump to line 2960 because the condition on line 2959 was never true
2960 verbose_logger.debug("prisma_client is None")
2961 return None
2963 user_id: Final = user_api_key_auth.user_id
2964 object_permission_id: Final = await MCPRequestHandler._user_object_permission_id(user_id, prisma_client)
2965 if object_permission_id is None: 2965 ↛ 2968line 2965 didn't jump to line 2968 because the condition on line 2965 was always true
2966 return None
2968 object_permission: Final = await get_object_permission(
2969 object_permission_id=object_permission_id,
2970 prisma_client=prisma_client,
2971 user_api_key_cache=user_api_key_cache,
2972 parent_otel_span=user_api_key_auth.parent_otel_span,
2973 proxy_logging_obj=proxy_logging_obj,
2974 )
2975 if object_permission is None:
2976 raise ValueError(
2977 f"user {user_id!r} names object_permission_id {object_permission_id!r} which could not be loaded"
2978 )
2979 return object_permission
2981 @staticmethod
2982 async def _user_object_permission_id(user_id: str, prisma_client: "PrismaClient") -> str | None:
2983 """The permission row this human's user row links to, or None when they link none.
2985 Caches the link (with a sentinel for "links none") so a human without an entitlement costs no
2986 DB read per MCP request. Anything other than an id string is treated as a cache MISS rather
2987 than carried into the permission lookup, and a read that fails answers None: not knowing
2988 whether someone is entitled is the state that existed before this level, so it places no
2989 ceiling. Only a link we DID resolve can make the caller deny.
2990 """
2991 from litellm.proxy.proxy_server import user_api_key_cache
2993 cache_key: Final = user_object_permission_id_cache_key(user_id)
2994 try:
2995 cached: Final[object] = await user_api_key_cache.async_get_cache(key=cache_key)
2996 if cached == USER_NO_MCP_PERMISSION_SENTINEL:
2997 return None
2998 if isinstance(cached, str) and cached: 2998 ↛ 2999line 2998 didn't jump to line 2999 because the condition on line 2998 was never true
2999 return cached
3000 user_row: Final = await UserRepository(prisma_client).table.find_unique(where={"user_id": user_id})
3001 linked: Final[object] = getattr(user_row, "object_permission_id", None) if user_row is not None else None
3002 object_permission_id: Final = linked if isinstance(linked, str) and linked else None
3003 await user_api_key_cache.async_set_cache(
3004 key=cache_key,
3005 value=object_permission_id or USER_NO_MCP_PERMISSION_SENTINEL,
3006 ttl=get_management_object_ttl(user_api_key_cache),
3007 )
3008 return object_permission_id
3009 except Exception as e: # noqa: BLE001 # unknown whether entitled at all: no ceiling, as before
3010 verbose_logger.warning("MCP user entitlement: link for %r unresolved, no ceiling: %s", user_id, e)
3011 return None
3013 @staticmethod
3014 async def _get_allowed_mcp_servers_for_user(
3015 user_api_key_auth: UserAPIKeyAuth | None = None,
3016 ) -> Sequence[str] | None:
3017 """The MCP servers the internal user is entitled to, as server ids.
3019 ``[]`` means this human places no restriction (allow-all from this level); ``None`` means the
3020 ceiling is UNRESOLVED, which the caller denies on. Servers named only under
3021 ``mcp_tool_permissions`` or reached through ``mcp_toolsets`` count as entitled, exactly as
3022 they do for a key or a team, so granting one tool never requires naming its server twice.
3023 """
3024 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
3025 global_mcp_server_manager,
3026 )
3028 try:
3029 object_permissions: Final = await MCPRequestHandler._get_user_object_permission(user_api_key_auth)
3030 if object_permissions is None: 3030 ↛ 3033line 3030 didn't jump to line 3033 because the condition on line 3030 was always true
3031 return []
3033 direct_mcp_servers = global_mcp_server_manager.expand_permission_list(object_permissions.mcp_servers or [])
3034 access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups(
3035 object_permissions.mcp_access_groups or []
3036 )
3037 tool_perm_servers: Final = list(
3038 global_mcp_server_manager.expand_tool_permissions(object_permissions.mcp_tool_permissions).keys()
3039 )
3040 toolset_grants: Final = await MCPRequestHandler._toolset_tool_permissions(object_permissions)
3041 return tuple({*direct_mcp_servers, *access_group_servers, *tool_perm_servers, *toolset_grants})
3042 except Exception as e: # noqa: BLE001 # any resolution fault is an unresolved ceiling, never "no ceiling"
3043 verbose_logger.warning("Failed to get allowed MCP servers for user: %s", e)
3044 return None
3046 @staticmethod
3047 async def _apply_user_server_ceiling(
3048 allowed_mcp_servers: Sequence[str],
3049 user_api_key_auth: UserAPIKeyAuth | None = None,
3050 *,
3051 keyless_source: bool = False,
3052 ) -> tuple[tuple[str, ...], bool]:
3053 """Narrow a resolved server list by the internal user's own entitlement.
3055 Returns the capped list and whether this human restricted it at all; the caller needs the
3056 second value because an org list may only CAP a lower-level restriction, never replace one, so
3057 a user ceiling has to be visible to the org step.
3059 RAISES when the entitlement is known but unreadable, which the resolver's own handler turns
3060 into deny-all. That is the point of the level: dropping a ceiling we know exists is exactly the
3061 silent widening it is there to prevent.
3062 """
3063 if keyless_source: 3063 ↛ 3064line 3063 didn't jump to line 3064 because the condition on line 3063 was never true
3064 return tuple(allowed_mcp_servers), False
3065 entitled: Final = await MCPRequestHandler._get_allowed_mcp_servers_for_user(user_api_key_auth)
3066 if entitled is None: 3066 ↛ 3067line 3066 didn't jump to line 3067 because the condition on line 3066 was never true
3067 raise ValueError(
3068 f"MCP user ceiling unresolvable for user_id="
3069 f"{user_api_key_auth.user_id if user_api_key_auth else None!r}"
3070 )
3071 if not entitled: 3071 ↛ 3073line 3071 didn't jump to line 3073 because the condition on line 3071 was always true
3072 return tuple(allowed_mcp_servers), False
3073 capped: Final = tuple(server for server in allowed_mcp_servers if server in set(entitled))
3074 verbose_logger.debug("Applied user ceiling filter. Final allowed servers: %s", capped)
3075 return capped, True
3077 @staticmethod
3078 async def _apply_agent_caller_ceiling(
3079 allowed_mcp_servers: Sequence[str],
3080 user_api_key_auth: UserAPIKeyAuth | None = None,
3081 ) -> tuple[tuple[str, ...], bool]:
3082 """Narrow an agent key's servers to those the invoking user and team (echoed back by the agent
3083 as ``x-litellm-user-id`` / ``x-litellm-team-id``) may reach: the echoed team's grants when it
3084 names any, then the echoed user's own entitlement. Raises like the user ceiling when that
3085 entitlement is known but unreadable, so the resolver denies rather than widens."""
3086 caller_auth: Final = agent_caller_auth(user_api_key_auth) if user_api_key_auth else None
3087 if caller_auth is None: 3087 ↛ 3089line 3087 didn't jump to line 3089 because the condition on line 3087 was always true
3088 return tuple(allowed_mcp_servers), False
3089 team_servers: Final = frozenset(await MCPRequestHandler._get_allowed_mcp_servers_for_team(caller_auth))
3090 team_capped: Final = (
3091 tuple(server for server in allowed_mcp_servers if server in team_servers)
3092 if team_servers
3093 else tuple(allowed_mcp_servers)
3094 )
3095 user_capped, user_restricts = await MCPRequestHandler._apply_user_server_ceiling(team_capped, caller_auth)
3096 verbose_logger.debug("Applied agent caller ceiling. Final allowed servers: %s", user_capped)
3097 return user_capped, bool(team_servers) or user_restricts
3099 @staticmethod
3100 async def _user_places_mcp_ceiling(user_api_key_auth: UserAPIKeyAuth | None = None) -> bool:
3101 """Whether this human's own entitlement bounds their MCP access at all.
3103 True when they are entitled to a specific set of servers, and also when that entitlement is
3104 UNRESOLVED — a caller uses this to decide whether it may skip the resolver, and skipping it on
3105 a transient fault would widen access.
3106 """
3107 entitled_servers: Final = await MCPRequestHandler._get_allowed_mcp_servers_for_user(user_api_key_auth)
3108 return entitled_servers is None or len(entitled_servers) > 0
3110 @staticmethod
3111 async def admin_view_unscoped(user_api_key_auth: UserAPIKeyAuth | None = None) -> bool:
3112 """Whether this principal's admin-view role grants the unscoped MCP resolution, whatever
3113 credential carries it (admin key, dashboard session, or OAuth-admitted session subject).
3115 Two bounds disqualify, one per ownership of the row. A CREDENTIAL's explicit
3116 ``object_permission.mcp_servers`` scope wins even for admins, including the empty list. An
3117 admitted subject's object_permission is the user's own row, whose ``mcp_servers`` column is
3118 [] by DB default, so for that shape the row binds through the entitlement ceiling instead
3119 (any non-empty entitlement, or an unresolved one, disqualifies), exactly as
3120 ``operator_open_server_ids`` reads the same row. The one owner of this predicate: the
3121 server-axis registry resolution in ``get_allowed_mcp_servers`` and the tools-axis open
3122 channel in ``_resolve_admitted_subject_tools`` both consult it, so the two axes cannot
3123 disagree."""
3124 if user_api_key_auth is None or not user_api_key_has_admin_view(user_api_key_auth): 3124 ↛ 3125line 3124 didn't jump to line 3125 because the condition on line 3124 was never true
3125 return False
3126 object_permission: Final = user_api_key_auth.object_permission
3127 credential_scoped: Final = (
3128 not _is_mcp_admitted_user_subject(user_api_key_auth)
3129 and object_permission is not None
3130 and object_permission.mcp_servers is not None
3131 )
3132 if credential_scoped:
3133 return False
3134 return not await MCPRequestHandler._user_places_mcp_ceiling(user_api_key_auth)
3136 @staticmethod
3137 async def _apply_user_tool_ceiling(
3138 allowed_tools: Sequence[str] | None,
3139 server_id: str,
3140 user_api_key_auth: UserAPIKeyAuth | None = None,
3141 *,
3142 keyless_source: bool = False,
3143 ) -> Sequence[str] | None:
3144 """Narrow a key/team tool allowlist by the internal user's own tool entitlement.
3146 The human's entitlement can only ever narrow: a user naming tools on ``server_id`` intersects
3147 (and becomes the allowlist when no lower level restricts), while a user naming none places no
3148 restriction. Returns ``[]`` (deny every tool on this server) when the entitlement cannot be
3149 resolved, because the caller's own except-handler treats a raise as allow-all for key auth.
3150 """
3151 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
3152 global_mcp_server_manager,
3153 )
3155 if keyless_source: 3155 ↛ 3156line 3155 didn't jump to line 3156 because the condition on line 3155 was never true
3156 return allowed_tools
3158 try:
3159 object_permissions: Final = await MCPRequestHandler._get_user_object_permission(user_api_key_auth)
3160 except Exception as e: # noqa: BLE001 # an unresolved human entitlement must deny, not widen
3161 verbose_logger.warning("MCP user tool ceiling unresolvable, denying tools on %r: %s", server_id, e)
3162 return []
3164 if object_permissions is None: 3164 ↛ 3167line 3164 didn't jump to line 3167 because the condition on line 3164 was always true
3165 return allowed_tools
3167 user_direct_tools: Final = global_mcp_server_manager.expand_tool_permissions(
3168 object_permissions.mcp_tool_permissions
3169 ).get(server_id)
3170 user_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(object_permissions, server_id)
3171 user_tools: Final = MCPRequestHandler._union_tool_grants(user_direct_tools, user_toolset_tools)
3172 if user_tools is None:
3173 return allowed_tools
3174 if allowed_tools is None:
3175 return list(user_tools)
3176 return list(set(allowed_tools) & set(user_tools))
3178 @staticmethod
3179 async def _apply_agent_caller_tool_ceiling(
3180 allowed_tools: Sequence[str] | None,
3181 server_id: str,
3182 user_api_key_auth: UserAPIKeyAuth | None = None,
3183 ) -> Sequence[str] | None:
3184 """Narrow an agent key's tools on ``server_id`` to those the invoking user and team (echoed back
3185 by the agent as ``x-litellm-user-id`` / ``x-litellm-team-id``) may call: the echoed team's tool
3186 grants when it names any on this server, then the echoed user's own tool entitlement. The tools
3187 axis twin of ``_apply_agent_caller_ceiling``, so the headers only ever narrow. Denies every tool
3188 on the server when the caller's team cannot be loaded, since a caller we cannot resolve must not
3189 read as unrestricted."""
3190 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
3191 global_mcp_server_manager,
3192 )
3194 caller_auth: Final = agent_caller_auth(user_api_key_auth) if user_api_key_auth else None
3195 if caller_auth is None: 3195 ↛ 3197line 3195 didn't jump to line 3197 because the condition on line 3195 was always true
3196 return allowed_tools
3197 try:
3198 team_obj_perm: Final = await MCPRequestHandler._get_team_object_permission(caller_auth)
3199 team_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(team_obj_perm, server_id)
3200 except Exception as e: # noqa: BLE001 # an unresolved caller team must deny, not widen
3201 verbose_logger.warning(
3202 "MCP agent caller team tool ceiling unresolvable, denying tools on %r: %s", server_id, e
3203 )
3204 return ()
3205 team_direct_tools: Final = (
3206 global_mcp_server_manager.expand_tool_permissions(team_obj_perm.mcp_tool_permissions).get(server_id)
3207 if team_obj_perm
3208 else None
3209 )
3210 team_tools: Final = MCPRequestHandler._union_tool_grants(team_direct_tools, team_toolset_tools)
3211 team_capped: Final = (
3212 allowed_tools
3213 if team_tools is None
3214 else tuple(team_tools)
3215 if allowed_tools is None
3216 else tuple(frozenset(allowed_tools) & frozenset(team_tools))
3217 )
3218 return await MCPRequestHandler._apply_user_tool_ceiling(team_capped, server_id, caller_auth)
3220 @staticmethod
3221 async def _apply_end_user_tool_ceiling(
3222 allowed_tools: Sequence[str] | None,
3223 server_id: str,
3224 user_api_key_auth: UserAPIKeyAuth | None = None,
3225 ) -> Sequence[str] | None:
3226 """Narrow a key/team tool allowlist by the end user's (customer's) tool entitlement."""
3227 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
3228 global_mcp_server_manager,
3229 )
3230 from litellm.proxy.proxy_server import prisma_client
3232 if user_api_key_auth is None or not user_api_key_auth.end_user_id or prisma_client is None: 3232 ↛ 3235line 3232 didn't jump to line 3235 because the condition on line 3232 was always true
3233 return allowed_tools
3235 object_permissions: Final = await MCPRequestHandler._get_end_user_object_permission(
3236 user_api_key_auth, prisma_client
3237 )
3238 if object_permissions is None:
3239 return allowed_tools
3241 end_user_direct_tools: Final = global_mcp_server_manager.expand_tool_permissions(
3242 object_permissions.mcp_tool_permissions
3243 ).get(server_id)
3244 end_user_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(object_permissions, server_id)
3245 end_user_tools: Final = MCPRequestHandler._union_tool_grants(end_user_direct_tools, end_user_toolset_tools)
3246 if end_user_tools is None:
3247 return allowed_tools
3248 if allowed_tools is None:
3249 return list(end_user_tools)
3250 return list(set(allowed_tools) & set(end_user_tools))
3252 # Sentinel stored in cache when an agent has no object_permission, so we
3253 # don't re-query the DB on every MCP request for that agent.
3254 _AGENT_NO_PERMISSION_SENTINEL = "__agent_no_mcp_permission__"
3256 @staticmethod
3257 async def _agent_object_permission_id(agent_id: str, prisma_client: "PrismaClient") -> str | None:
3258 """The permission row this agent's row links to, or ``None`` when it links none.
3260 Caches the link (with a sentinel for "links none") so an agent without an entitlement costs
3261 no DB read per MCP request. A read that fails also answers ``None``: not knowing whether the
3262 agent is entitled is the state that existed before this level, so it places no ceiling. Only
3263 a link we DID resolve can make the caller deny."""
3264 from litellm.proxy.proxy_server import user_api_key_cache
3266 cache_key: Final = f"agent_object_permission_id:{agent_id}"
3267 try:
3268 cached: Final[object] = await user_api_key_cache.async_get_cache(key=cache_key)
3269 if cached == MCPRequestHandler._AGENT_NO_PERMISSION_SENTINEL:
3270 return None
3271 if isinstance(cached, str) and cached:
3272 return cached
3273 agent_row: Final = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
3274 linked: Final[object] = getattr(agent_row, "object_permission_id", None) if agent_row is not None else None
3275 object_permission_id: Final = linked if isinstance(linked, str) and linked else None
3276 await user_api_key_cache.async_set_cache(
3277 key=cache_key,
3278 value=object_permission_id or MCPRequestHandler._AGENT_NO_PERMISSION_SENTINEL,
3279 ttl=get_management_object_ttl(user_api_key_cache),
3280 )
3281 return object_permission_id
3282 except Exception as e: # noqa: BLE001 # entitlement unknown, not known-absent: no ceiling, as before this level
3283 verbose_logger.warning("Failed to resolve object_permission_id for agent %r: %s", agent_id, e)
3284 return None
3286 @staticmethod
3287 async def _get_agent_object_permission(
3288 user_api_key_auth: UserAPIKeyAuth | None = None,
3289 ) -> LiteLLM_ObjectPermissionTable | None:
3290 """
3291 Get agent object_permission via the established ``get_object_permission``
3292 helper. Caches the ``agent_id -> object_permission_id`` mapping so we
3293 avoid re-reading the agent row on every request, and reuses the shared
3294 ``object_permission_id`` cache populated by the org / team / key paths.
3296 ``None`` means the agent places NO restriction: no ``agent_id``, no DB, or an agent linking
3297 no permission. An agent that LINKS one we cannot load raises ``UnloadableEntitlementError``,
3298 since a known entitlement with unknown contents must deny rather than read as unrestricted.
3299 """
3300 from litellm.proxy.proxy_server import prisma_client
3302 if not user_api_key_auth or not user_api_key_auth.agent_id:
3303 return None
3305 if prisma_client is None:
3306 verbose_logger.debug("prisma_client is None")
3307 return None
3309 agent_id: Final = user_api_key_auth.agent_id
3310 object_permission_id: Final = await MCPRequestHandler._agent_object_permission_id(agent_id, prisma_client)
3311 if object_permission_id is None:
3312 return None
3314 return await MCPRequestHandler._load_named_object_permission(
3315 principal=f"agent {agent_id!r}",
3316 object_permission_id=object_permission_id,
3317 prisma_client=prisma_client,
3318 user_api_key_auth=user_api_key_auth,
3319 )
3321 @staticmethod
3322 async def _get_allowed_mcp_servers_for_agent(
3323 user_api_key_auth: UserAPIKeyAuth | None = None,
3324 agent_object_permission: LiteLLM_ObjectPermissionTable | None = None,
3325 ) -> list[str]:
3326 """
3327 Get allowed MCP servers for an agent (from the agent's object_permission).
3329 Returns the agent's direct servers, the servers in its access groups, and the servers reached
3330 through its toolsets, exactly as the key, team, and org levels count theirs. If agent has no
3331 object_permission, returns [] (no extra restriction). An entitlement the agent LINKS but that
3332 cannot be read, or a declared toolset that resolves to no grants, raises
3333 ``UnloadableEntitlementError`` out of here so the resolver denies instead of reading the
3334 agent as unrestricted.
3336 Args:
3337 user_api_key_auth: User auth with agent_id
3338 agent_object_permission: Pre-fetched object_permission to avoid duplicate DB query.
3339 If None, will be fetched from DB.
3340 """
3341 if not user_api_key_auth or not user_api_key_auth.agent_id:
3342 return []
3344 obj_perm: Final = (
3345 agent_object_permission
3346 if agent_object_permission is not None
3347 else await MCPRequestHandler._get_agent_object_permission(user_api_key_auth)
3348 )
3349 if obj_perm is None:
3350 return []
3352 try:
3353 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
3354 global_mcp_server_manager,
3355 )
3357 expanded_direct_servers: Final = global_mcp_server_manager.expand_permission_list(
3358 obj_perm.mcp_servers or []
3359 )
3360 access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups(
3361 obj_perm.mcp_access_groups or []
3362 )
3363 toolset_grants: Final = await MCPRequestHandler._toolset_tool_permissions(obj_perm)
3364 return list({*expanded_direct_servers, *access_group_servers, *toolset_grants})
3365 except Exception as e:
3366 if isinstance(e, UnloadableEntitlementError):
3367 raise
3368 verbose_logger.warning("Failed to get allowed MCP servers for agent: %s", e)
3369 return []
3371 @staticmethod
3372 async def _get_agent_access_group_server_ceiling(
3373 user_api_key_auth: UserAPIKeyAuth,
3374 resolve_ceiling: CeilingResolver = resolve_agent_access_group_ceiling,
3375 ) -> frozenset[str] | None:
3376 """
3377 Server IDs the agent's attached unified access groups (``LiteLLM_AgentsTable.access_group_ids``)
3378 allow, or None when the agent has none attached. Unlike the object_permission path above, an
3379 attached group set that names no servers is an empty ceiling and denies every server.
3380 """
3381 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
3382 global_mcp_server_manager,
3383 )
3385 if not user_api_key_auth.agent_id:
3386 return None
3387 ceiling: Final = await resolve_ceiling(user_api_key_auth.agent_id)
3388 if ceiling is None:
3389 return None
3390 return frozenset(global_mcp_server_manager.expand_permission_list(sorted(ceiling.mcp_server_ids)))
3392 @staticmethod
3393 async def _get_agent_tool_permissions_for_server(
3394 server_id: str,
3395 user_api_key_auth: UserAPIKeyAuth | None = None,
3396 agent_object_permission: LiteLLM_ObjectPermissionTable | None = None,
3397 ) -> list[str] | None:
3398 """
3399 Get allowed tool names for a server from the agent's object_permission: the union of its
3400 direct tool permissions and the tools its toolsets grant on that server, mirroring the key and
3401 team levels. Returns None if agent has no tool restrictions for this server. An entitlement the
3402 agent LINKS but that cannot be read, or a declared toolset that resolves to no grants, raises
3403 ``UnloadableEntitlementError`` out of here, which the tool resolver turns into deny-all for the
3404 server rather than an unrestricted tool list.
3406 Args:
3407 server_id: Server ID to check permissions for
3408 user_api_key_auth: User auth with agent_id
3409 agent_object_permission: Pre-fetched object_permission to avoid duplicate DB query.
3410 If None, will be fetched from DB.
3411 """
3412 if not user_api_key_auth or not user_api_key_auth.agent_id:
3413 return None
3415 obj_perm: Final = (
3416 agent_object_permission
3417 if agent_object_permission is not None
3418 else await MCPRequestHandler._get_agent_object_permission(user_api_key_auth)
3419 )
3420 if obj_perm is None:
3421 return None
3423 try:
3424 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
3425 global_mcp_server_manager,
3426 )
3428 direct_tools: Final = (
3429 global_mcp_server_manager.expand_tool_permissions(obj_perm.mcp_tool_permissions).get(server_id)
3430 if obj_perm.mcp_tool_permissions
3431 else None
3432 )
3433 toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(obj_perm, server_id)
3434 agent_tools: Final = MCPRequestHandler._union_tool_grants(direct_tools, toolset_tools)
3435 return list(agent_tools) if agent_tools else None
3436 except Exception as e:
3437 if isinstance(e, UnloadableEntitlementError):
3438 raise
3439 verbose_logger.warning("Failed to get agent tool permissions for server: %s", e)
3440 return None
3442 @staticmethod
3443 def _get_config_server_ids_for_access_groups(config_mcp_servers, access_groups: list[str]) -> set[str]:
3444 """
3445 Helper to get server_ids from config-loaded servers that match any of the given access groups.
3446 """
3447 server_ids: Final[set[str]] = set()
3448 for server_id, server in config_mcp_servers.items(): 3448 ↛ 3449line 3448 didn't jump to line 3449 because the loop on line 3448 never started
3449 if server.access_groups:
3450 if any(group in server.access_groups for group in access_groups):
3451 server_ids.add(server_id)
3452 return server_ids
3454 @staticmethod
3455 async def _get_db_server_ids_for_access_groups(prisma_client, access_groups: list[str]) -> set[str]:
3456 """
3457 Helper to get server_ids from DB servers that match any of the given access groups.
3458 """
3459 server_ids: Final[set[str]] = set()
3460 if access_groups and prisma_client is not None:
3461 try:
3462 mcp_servers: Final = await MCPServerRepository(prisma_client).table.find_many(
3463 where={"mcp_access_groups": {"hasSome": access_groups}}
3464 )
3465 for server in mcp_servers: 3465 ↛ 3466line 3465 didn't jump to line 3466 because the loop on line 3465 never started
3466 server_ids.add(server.server_id)
3467 except Exception as e:
3468 verbose_logger.debug("Error getting MCP servers from access groups: %s", e)
3469 return server_ids
3471 @staticmethod
3472 async def _get_mcp_servers_from_access_groups(
3473 access_groups: list[str],
3474 ) -> list[str]:
3475 """
3476 Resolve MCP access groups to server IDs by querying BOTH the MCP server table (DB) AND config-loaded servers
3477 """
3478 from litellm.proxy.proxy_server import prisma_client
3480 try:
3481 # Import here to avoid circular import
3482 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
3483 global_mcp_server_manager,
3484 )
3486 # Use the new helper for config-loaded servers
3487 server_ids: Final = MCPRequestHandler._get_config_server_ids_for_access_groups(
3488 global_mcp_server_manager.config_mcp_servers, access_groups
3489 )
3491 # Use the new helper for DB servers
3492 db_server_ids = await MCPRequestHandler._get_db_server_ids_for_access_groups(prisma_client, access_groups)
3493 server_ids.update(db_server_ids)
3495 return list(server_ids)
3496 except Exception as e:
3497 verbose_logger.warning("Failed to get MCP servers from access groups: %s", e)
3498 return []
3500 @staticmethod
3501 async def get_mcp_access_groups(
3502 user_api_key_auth: UserAPIKeyAuth | None = None,
3503 ) -> list[str]:
3504 """
3505 Get list of MCP access groups for the given user/key based on permissions
3506 """
3507 access_groups: list[str] = []
3508 access_groups_for_key: Final = await MCPRequestHandler._get_mcp_access_groups_for_key(user_api_key_auth)
3509 access_groups_for_team: Final = await MCPRequestHandler._get_mcp_access_groups_for_team(user_api_key_auth)
3511 #########################################################
3512 # If team has access groups, then key must have a subset of the team's access groups
3513 #########################################################
3514 if len(access_groups_for_team) > 0:
3515 for access_group in access_groups_for_key:
3516 if access_group in access_groups_for_team:
3517 access_groups.append(access_group)
3518 else:
3519 access_groups = access_groups_for_key
3521 return list(set(access_groups))
3523 @staticmethod
3524 async def _get_mcp_access_groups_for_key(
3525 user_api_key_auth: UserAPIKeyAuth | None = None,
3526 ) -> list[str]:
3527 from litellm.proxy.auth.auth_checks import get_object_permission
3528 from litellm.proxy.proxy_server import (
3529 prisma_client,
3530 proxy_logging_obj,
3531 user_api_key_cache,
3532 )
3534 if user_api_key_auth is None:
3535 return []
3537 if user_api_key_auth.object_permission_id is None:
3538 return []
3540 if prisma_client is None:
3541 verbose_logger.debug("prisma_client is None")
3542 return []
3544 try:
3545 key_object_permission: Final = await get_object_permission(
3546 object_permission_id=user_api_key_auth.object_permission_id,
3547 prisma_client=prisma_client,
3548 user_api_key_cache=user_api_key_cache,
3549 parent_otel_span=user_api_key_auth.parent_otel_span,
3550 proxy_logging_obj=proxy_logging_obj,
3551 )
3552 if key_object_permission is None:
3553 return []
3555 return key_object_permission.mcp_access_groups or []
3556 except Exception as e:
3557 verbose_logger.warning("Failed to get MCP access groups for key: %s", e)
3558 return []
3560 @staticmethod
3561 async def _get_mcp_access_groups_for_team(
3562 user_api_key_auth: UserAPIKeyAuth | None = None,
3563 ) -> list[str]:
3564 """
3565 Get MCP access groups for the team
3566 """
3567 from litellm.proxy.auth.auth_checks import get_team_object
3568 from litellm.proxy.proxy_server import (
3569 prisma_client,
3570 proxy_logging_obj,
3571 user_api_key_cache,
3572 )
3574 if user_api_key_auth is None:
3575 return []
3577 if user_api_key_auth.team_id is None:
3578 return []
3580 if prisma_client is None:
3581 verbose_logger.debug("prisma_client is None")
3582 return []
3584 if user_api_key_auth.team_id == UI_TEAM_ID:
3585 return []
3587 try:
3588 team_obj: Final[LiteLLM_TeamTable | None] = await get_team_object(
3589 team_id=user_api_key_auth.team_id,
3590 prisma_client=prisma_client,
3591 user_api_key_cache=user_api_key_cache,
3592 parent_otel_span=user_api_key_auth.parent_otel_span,
3593 proxy_logging_obj=proxy_logging_obj,
3594 )
3595 if team_obj is None:
3596 verbose_logger.debug("team_obj is None")
3597 return []
3599 object_permissions: Final = team_obj.object_permission
3600 if object_permissions is None:
3601 return []
3603 return object_permissions.mcp_access_groups or []
3604 except Exception as e:
3605 verbose_logger.warning("Failed to get MCP access groups for team: %s", e)
3606 return []
3608 @staticmethod
3609 def get_mcp_access_groups_from_headers(headers: Headers) -> list[str] | None:
3610 """
3611 Extract and parse the x-mcp-access-groups header as a list of strings.
3612 """
3613 mcp_access_groups_header: Final = headers.get(MCPRequestHandler.LITELLM_MCP_ACCESS_GROUPS_HEADER_NAME)
3614 if mcp_access_groups_header is not None:
3615 try:
3616 return [s.strip() for s in mcp_access_groups_header.split(",") if s.strip()]
3617 except Exception:
3618 return None
3619 return None
3621 @staticmethod
3622 def get_mcp_access_groups_from_scope(scope: Scope) -> list[str] | None:
3623 """
3624 Extract and parse the x-mcp-access-groups header from an ASGI scope.
3625 """
3626 headers: Final = MCPRequestHandler._safe_get_headers_from_scope(scope)
3627 return MCPRequestHandler.get_mcp_access_groups_from_headers(headers)