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

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 

7 

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 

13 

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 

72 

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 

75 

76 

77_EMPTY_TOOLSET_GRANTS: Final[Mapping[str, Sequence[str]]] = MappingProxyType({}) 

78 

79 

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) 

84 

85 

86class UnloadableEntitlementError(Exception): 

87 """A principal's row NAMES an ``object_permission_id`` whose contents could not be read. 

88 

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. 

92 

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

96 

97 

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

105 

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 

130 

131 

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. 

138 

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 ) 

147 

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 

153 

154 

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 

164 

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 

174 

175 

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 

185 

186 

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) 

199 

200 

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) 

206 

207 

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 ) 

221 

222 

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

226 

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 

231 

232 

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 ) 

242 

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] 

250 

251 

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. 

262 

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 

276 

277 

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. 

289 

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 ) 

309 

310 

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. 

321 

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 

362 

363 

364@dataclass(frozen=True, slots=True) 

365class MCPServerAccess: 

366 server_ids: tuple[str, ...] 

367 scope: Literal["unscoped", "scoped", "unresolved"] = "unscoped" 

368 

369 

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

375 

376 requested_name: str 

377 server: MCPServer 

378 

379 

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 

386 

387 Utilizes the main `user_api_key_auth` function to validate authentication 

388 

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: 

394 

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

405 

406 LITELLM_API_KEY_HEADER_NAME_PRIMARY = SpecialHeaders.custom_litellm_api_key.value 

407 LITELLM_API_KEY_HEADER_NAME_SECONDARY = SpecialHeaders.openai_authorization.value 

408 

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 

411 

412 LITELLM_MCP_SERVERS_HEADER_NAME = SpecialHeaders.mcp_servers.value 

413 

414 LITELLM_MCP_ACCESS_GROUPS_HEADER_NAME = SpecialHeaders.mcp_access_groups.value 

415 

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. 

435 

436 Args: 

437 scope: ASGI scope containing request information 

438 

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) 

450 

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 

453 

454 litellm_api_key: Final = MCPRequestHandler.get_litellm_api_key_from_headers(headers) or "" 

455 

456 # Get the old mcp_auth_header for backward compatibility 

457 mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers(headers) 

458 

459 # Get the new server-specific auth headers 

460 mcp_server_auth_headers = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) 

461 

462 # Get the oauth2 headers 

463 oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(headers) 

464 

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) 

480 

481 async def mock_body(): 

482 return b"{}" 

483 

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 ) 

489 

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 ) 

604 

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 ) 

622 

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 ) 

631 

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

638 

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 

653 

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 

658 

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 } 

664 

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 

668 

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} 

676 

677 return oauth2_headers, raw_headers, mcp_auth_header, mcp_server_auth_headers 

678 

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. 

686 

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. 

694 

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

712 

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 [] 

723 

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()] 

732 

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] 

738 

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. 

744 

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 

753 

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 

757 

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 

763 

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

769 

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 ) 

779 

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) 

794 

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. 

805 

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

819 

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 

848 

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. 

858 

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 

866 

867 if not master_key: 

868 raise HTTPException(status_code=500, detail="Server misconfigured: master_key is not set") 

869 

870 await MCPRequestHandler._run_pre_db_read_auth_checks(request=request, route=route) 

871 

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) 

882 

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. 

894 

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 

937 

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 

966 

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. 

970 

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 ) 

987 

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. 

996 

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 

1012 

1013 if not master_key: 

1014 raise HTTPException(status_code=500, detail="Server misconfigured: master_key is not set") 

1015 

1016 await MCPRequestHandler._run_pre_db_read_auth_checks(request=request, route=route) 

1017 

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) 

1049 

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 

1062 

1063 await pre_db_read_auth_checks( 

1064 request=request, 

1065 request_data=await _read_request_body(request=request), 

1066 route=route, 

1067 ) 

1068 

1069 @staticmethod 

1070 async def _reload_admitted_principal(identity: EnvelopeIdentity) -> UserAPIKeyAuth: 

1071 """Reload the live litellm record the envelope's subject references. 

1072 

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) 

1087 

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. 

1091 

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. 

1097 

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 

1105 

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 

1156 

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. 

1161 

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 ) 

1172 

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 

1203 

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. 

1207 

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 

1226 

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 

1247 

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. 

1254 

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 

1258 

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 

1265 

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. 

1269 

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 

1281 

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") 

1296 

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. 

1300 

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

1309 

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 

1321 

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 

1338 

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) 

1353 

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``). 

1359 

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 [] 

1373 

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 

1378 

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 

1382 

1383 Support this auth: https://docs.litellm.ai/docs/mcp#using-your-mcp-with-client-side-credentials 

1384 

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. 

1386 

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 

1397 

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. 

1404 

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 

1410 

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

1416 

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 

1425 

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 

1433 

1434 # Convert common header names to proper case 

1435 if auth_header_name == "authorization": 

1436 auth_header_name = "Authorization" 

1437 

1438 # Initialize server dict if not exists 

1439 if server_alias not in server_auth_headers: 

1440 server_auth_headers[server_alias] = {} 

1441 

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 ) 

1449 

1450 return server_auth_headers 

1451 

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 

1462 

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 

1467 

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 

1474 

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 

1485 

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 

1490 

1491 1. Check if `x-litellm-api-key` is in the headers 

1492 2. If not, check if `Authorization` is in the headers 

1493 

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 

1501 

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 

1505 

1506 return None 

1507 

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. 

1513 

1514 ASGI headers are in format: List[List[bytes, bytes]] 

1515 We need to convert them to the format Headers expects. 

1516 

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({}) 

1536 

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 ) 

1559 

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) 

1571 

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. 

1580 

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. 

1591 

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. 

1594 

1595 Returns: 

1596 List[str]: List of allowed MCP servers by server id 

1597 """ 

1598 from litellm.proxy.proxy_server import general_settings 

1599 

1600 key_object_permission: Final = MCPRequestHandler._get_key_object_permission(user_api_key_auth) 

1601 

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 ) 

1611 

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) 

1614 

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") 

1619 

1620 allowed_mcp_servers_for_team = await MCPRequestHandler._get_allowed_mcp_servers_for_team(user_api_key_auth) 

1621 

1622 key_access_group_grants = await MCPRequestHandler._get_key_access_group_mcp_server_extras(user_api_key_auth) 

1623 

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) 

1630 

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 ) 

1636 

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 

1659 

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) 

1664 

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 ) 

1672 

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 ) 

1681 

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") 

1699 

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 ) 

1715 

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 ) 

1722 

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 

1731 

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 ) 

1741 

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 ) 

1762 

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. 

1772 

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. 

1778 

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 

1803 

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. 

1813 

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 

1833 

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. 

1838 

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 

1845 

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 

1864 

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. 

1868 

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 ) 

1879 

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 ) 

1911 

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 

1933 

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. 

1937 

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 ] 

1946 

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) 

1955 

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. 

1959 

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 ) 

1971 

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 

1985 

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

1994 

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 "") 

2008 

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. 

2013 

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 ) 

2021 

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) 

2030 

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) 

2042 

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. 

2049 

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 

2055 

2056 return user_api_key_auth.object_permission 

2057 

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. 

2064 

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 ) 

2074 

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 

2080 

2081 if user_api_key_auth.team_id == UI_TEAM_ID: 

2082 return None 

2083 

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 ) 

2092 

2093 if not team_obj: 

2094 return None 

2095 

2096 return team_obj.object_permission 

2097 

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. 

2105 

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 ) 

2113 

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 

2124 

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) 

2133 

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 ())}) 

2148 

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 ) 

2165 

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 ) 

2175 

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. 

2182 

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 

2200 

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. 

2211 

2212 Args: 

2213 server_id: Server ID to check permissions for 

2214 user_api_key_auth: User auth 

2215 

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 

2221 

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) 

2228 

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) 

2232 

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 ) 

2240 

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 ) 

2246 

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 ) 

2258 

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 ) 

2265 

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) 

2270 

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) 

2282 

2283 allowed_tools = _as_list( 

2284 await MCPRequestHandler._apply_end_user_tool_ceiling(allowed_tools, server_id, user_api_key_auth) 

2285 ) 

2286 

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 ) 

2292 

2293 allowed_tools = _as_list( 

2294 await MCPRequestHandler._apply_agent_caller_tool_ceiling(allowed_tools, server_id, user_api_key_auth) 

2295 ) 

2296 

2297 return await MCPRequestHandler._apply_agent_and_org_tool_ceilings( 

2298 allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source 

2299 ) 

2300 

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 

2315 

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. 

2325 

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 ) 

2333 

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 ) 

2346 

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 ) 

2374 

2375 return allowed_tools 

2376 

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. 

2380 

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 

2388 

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. 

2397 

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 

2402 

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) 

2411 

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 

2425 

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 ) 

2453 

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 [] 

2467 

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

2475 

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 ) 

2495 

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 [] 

2508 

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] 

2513 

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 ) 

2518 

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 ) 

2523 

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 ) 

2528 

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 ) 

2538 

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 [] 

2545 

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. 

2551 

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) 

2561 

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. 

2565 

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] 

2576 

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 ) 

2588 

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

2606 

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 ) 

2616 

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 ) 

2632 

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

2640 

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 ) 

2659 

2660 if not team_id or team_id == UI_TEAM_ID or prisma_client is None: 

2661 return [] 

2662 

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 ) 

2684 

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 [] 

2692 

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

2701 

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 

2708 

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 

2725 

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. 

2734 

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 ) 

2748 

2749 if not user_api_key_auth or not user_api_key_auth.org_id: 

2750 return None 

2751 

2752 if prisma_client is None: 

2753 verbose_logger.debug("prisma_client is None") 

2754 return None 

2755 

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 

2772 

2773 if org_obj is None or not org_obj.object_permission_id: 

2774 return None 

2775 

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 ) 

2784 

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. 

2791 

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. 

2795 

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) 

2802 

2803 if object_permissions is None: 

2804 return [] 

2805 

2806 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( 

2807 global_mcp_server_manager, 

2808 ) 

2809 

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 []) 

2812 

2813 access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups( 

2814 object_permissions.mcp_access_groups or [] 

2815 ) 

2816 

2817 tool_perm_servers: Final = list( 

2818 global_mcp_server_manager.expand_tool_permissions(object_permissions.mcp_tool_permissions).keys() 

2819 ) 

2820 

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) 

2824 

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 

2837 

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. 

2844 

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 

2852 

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 

2865 

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 ) 

2880 

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. 

2887 

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 

2893 

2894 if not user_api_key_auth or not user_api_key_auth.end_user_id: 

2895 return [] 

2896 

2897 if prisma_client is None: 

2898 verbose_logger.debug("prisma_client is None") 

2899 return [] 

2900 

2901 object_permission = await MCPRequestHandler._get_end_user_object_permission(user_api_key_auth, prisma_client) 

2902 if object_permission is None: 

2903 return [] 

2904 

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 ) 

2910 

2911 direct_mcp_servers = global_mcp_server_manager.expand_permission_list(object_permission.mcp_servers or []) 

2912 

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 ) 

2917 

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 ) 

2922 

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 [] 

2929 

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. 

2936 

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. 

2942 

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 ) 

2955 

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 

2958 

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 

2962 

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 

2967 

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 

2980 

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. 

2984 

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 

2992 

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 

3012 

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. 

3018 

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 ) 

3027 

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 [] 

3032 

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 

3045 

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. 

3054 

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. 

3058 

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 

3076 

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 

3098 

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. 

3102 

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 

3109 

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

3114 

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) 

3135 

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. 

3145 

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 ) 

3154 

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 

3157 

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 [] 

3163 

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 

3166 

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

3177 

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 ) 

3193 

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) 

3219 

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 

3231 

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 

3234 

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 

3240 

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

3251 

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__" 

3255 

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. 

3259 

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 

3265 

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 

3285 

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. 

3295 

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 

3301 

3302 if not user_api_key_auth or not user_api_key_auth.agent_id: 

3303 return None 

3304 

3305 if prisma_client is None: 

3306 verbose_logger.debug("prisma_client is None") 

3307 return None 

3308 

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 

3313 

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 ) 

3320 

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

3328 

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. 

3335 

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 [] 

3343 

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 [] 

3351 

3352 try: 

3353 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( 

3354 global_mcp_server_manager, 

3355 ) 

3356 

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 [] 

3370 

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 ) 

3384 

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

3391 

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. 

3405 

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 

3414 

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 

3422 

3423 try: 

3424 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( 

3425 global_mcp_server_manager, 

3426 ) 

3427 

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 

3441 

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 

3453 

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 

3470 

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 

3479 

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 ) 

3485 

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 ) 

3490 

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) 

3494 

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 [] 

3499 

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) 

3510 

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 

3520 

3521 return list(set(access_groups)) 

3522 

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 ) 

3533 

3534 if user_api_key_auth is None: 

3535 return [] 

3536 

3537 if user_api_key_auth.object_permission_id is None: 

3538 return [] 

3539 

3540 if prisma_client is None: 

3541 verbose_logger.debug("prisma_client is None") 

3542 return [] 

3543 

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 [] 

3554 

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 [] 

3559 

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 ) 

3573 

3574 if user_api_key_auth is None: 

3575 return [] 

3576 

3577 if user_api_key_auth.team_id is None: 

3578 return [] 

3579 

3580 if prisma_client is None: 

3581 verbose_logger.debug("prisma_client is None") 

3582 return [] 

3583 

3584 if user_api_key_auth.team_id == UI_TEAM_ID: 

3585 return [] 

3586 

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 [] 

3598 

3599 object_permissions: Final = team_obj.object_permission 

3600 if object_permissions is None: 

3601 return [] 

3602 

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 [] 

3607 

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 

3620 

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)