Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py: 28%

986 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1import asyncio 

2import html as _html 

3import json 

4import secrets 

5import time 

6from collections.abc import Callable, Mapping 

7from datetime import datetime, timezone 

8from typing import TYPE_CHECKING, Any, Final, Literal, Optional 

9from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse 

10 

11import httpx 

12from fastapi import APIRouter, Depends, Form, HTTPException, Request 

13from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response 

14from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError 

15 

16from litellm._logging import verbose_logger 

17from litellm.caching.in_memory_cache import InMemoryCache 

18from litellm.llms.custom_httpx.http_handler import ( 

19 get_async_httpx_client, 

20 httpxSpecialProvider, 

21) 

22from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import ( 

23 TokenEndpointAuthConfigError, 

24 normalize_token_endpoint_auth_method, 

25) 

26from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( 

27 _bridge_mint_error_response, 

28 _BridgeMintReady, 

29 _BridgeRefreshReady, 

30 _extract_user_id_from_request, 

31 _finish_bridge_mint, 

32 _prepare_bridge_mint, 

33 _prepare_bridge_refresh, 

34 _reload_active_user_by_id, 

35 authorize_oauth_credential_request, 

36 can_store_oauth_credential, 

37 oauth_authorization_uses_gateway_credential, 

38) 

39from litellm.proxy._experimental.mcp_server.faults import ( 

40 CallerRejected, 

41 CredentialSource, 

42 UpstreamProtocolFault, 

43 classify_upstream_dcr_rejection, 

44 classify_upstream_token_rejection, 

45 dcr_fault_detail, 

46 render_token_fault, 

47) 

48from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import ( 

49 VendorCredentialState, 

50 aggregate_authorize, 

51 aggregate_token, 

52 complete_connect_flow, 

53 describe_connect_flow, 

54 introspect_gateway_token, 

55 is_gateway_dcr_client_id, 

56 is_proxy_api_resource, 

57 native_client_auth_contract, 

58 native_client_authorize, 

59 register_aggregate_client, 

60 relative_request_url, 

61 revoke_refresh_token, 

62 supported_grant_types, 

63) 

64from litellm.proxy._experimental.mcp_server.idp_token_exchange import ( 

65 exchange_idp_subject_token, 

66 token_exchange_available, 

67) 

68from litellm.proxy._experimental.mcp_server.oauth_identity_binding import ( 

69 RefreshOwnershipProven, 

70 RefreshTokenPresented, 

71 enforce_oauth_identity_binding, 

72) 

73from litellm.proxy._experimental.mcp_server.oauth_utils import ( 

74 TOKEN_NO_CACHE_HEADERS, 

75 build_upstream_oauth2_token_request, 

76 get_request_base_url, 

77 resolve_upstream_resource, 

78 validate_trusted_redirect_uri, 

79 well_known_root_suffix, 

80) 

81from litellm.proxy._experimental.mcp_server.proxy_api_credentials import ( 

82 lookup_consent_teams, 

83 mint_proxy_credential, 

84) 

85from litellm.proxy.auth.ip_address_utils import IPAddressUtils 

86from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

87from litellm.proxy.common_utils.encrypt_decrypt_utils import ( 

88 decrypt_value_helper, 

89 encrypt_value_helper, 

90) 

91from litellm.proxy.common_utils.http_parsing_utils import _read_request_body 

92from litellm.types.mcp import MCPAuth, MCPCredentials 

93from litellm.types.mcp_server.mcp_server_manager import MCPServer, MCPTokenEndpointAuthMethod 

94 

95if TYPE_CHECKING: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 from litellm.proxy._types import LiteLLM_MCPServerTable 

97 

98# TTL cache for upstream OAuth metadata fetched from pass-through MCP servers. 

99# Keeps us from hammering the upstream IdP on each discovery request. 

100# Keyed by (server_id, resource_url) → (expires_at_epoch, payload). 

101# A payload of ``None`` is a negative-result entry that prevents repeated 

102# upstream fetches when the IdP consistently has no metadata to serve. 

103_OAUTH_METADATA_CACHE: Final[dict[tuple[str, str], tuple[float, dict | None]]] = {} 

104_OAUTH_METADATA_CACHE_TTL_SECONDS: Final = 300 

105_OAUTH_METADATA_NEGATIVE_CACHE_TTL_SECONDS: Final = 60 

106_OAUTH_METADATA_CACHE_MAX_SIZE: Final = 128 

107# Per-(server_id, resource_url) async locks so concurrent discovery requests 

108# coalesce onto a single upstream fetch instead of issuing N parallel calls. 

109_OAUTH_METADATA_FETCH_LOCKS: Final[dict[tuple[str, str], asyncio.Lock]] = {} 

110 

111router: Final = APIRouter( 

112 tags=["mcp"], 

113) 

114 

115 

116def _prune_oauth_metadata_cache(now: float | None = None) -> None: 

117 now = now if now is not None else time.time() 

118 expired_cache_keys: Final = [ 

119 cache_key for cache_key, (expires_at, _payload) in _OAUTH_METADATA_CACHE.items() if expires_at <= now 

120 ] 

121 for cache_key in expired_cache_keys: 

122 _OAUTH_METADATA_CACHE.pop(cache_key, None) 

123 

124 if len(_OAUTH_METADATA_CACHE) > _OAUTH_METADATA_CACHE_MAX_SIZE: 

125 overflow: Final = len(_OAUTH_METADATA_CACHE) - _OAUTH_METADATA_CACHE_MAX_SIZE 

126 cache_keys_by_expiry: Final = sorted( 

127 _OAUTH_METADATA_CACHE, 

128 key=lambda cache_key: _OAUTH_METADATA_CACHE[cache_key][0], 

129 ) 

130 for cache_key in cache_keys_by_expiry[:overflow]: 

131 _OAUTH_METADATA_CACHE.pop(cache_key, None) 

132 

133 # Drop locks whose cache entry has been evicted and that aren't currently 

134 # held; held locks stay so in-flight callers continue to coalesce. 

135 for cache_key in list(_OAUTH_METADATA_FETCH_LOCKS): 

136 if cache_key in _OAUTH_METADATA_CACHE: 

137 continue 

138 lock = _OAUTH_METADATA_FETCH_LOCKS.get(cache_key) 

139 if lock is None or lock.locked(): 

140 continue 

141 _OAUTH_METADATA_FETCH_LOCKS.pop(cache_key, None) 

142 

143 

144def encode_state_with_base_url( 

145 base_url: str, 

146 original_state: str, 

147 code_challenge: str | None = None, 

148 code_challenge_method: str | None = None, 

149 client_redirect_uri: str | None = None, 

150 litellm_user_id: str | None = None, 

151 mcp_server_id: str | None = None, 

152 dcr_client_id: str | None = None, 

153 dcr_client_secret: str | None = None, 

154 dcr_token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None, 

155 oauth_nonce: str | None = None, 

156) -> str: 

157 """ 

158 Encode the base_url, original state, and PKCE parameters using encryption. 

159 

160 Args: 

161 base_url: The base URL to encode 

162 original_state: The original state parameter 

163 code_challenge: PKCE code challenge from client 

164 code_challenge_method: PKCE code challenge method from client 

165 client_redirect_uri: Original redirect_uri from client 

166 litellm_user_id: The authenticated user captured for bridge or identity-bound per-user OAuth; 

167 the callback seals this credential owner into the authorization code 

168 mcp_server_id: The server the flow targets, sealed alongside litellm_user_id (bridge) or 

169 dcr_client_id (ephemeral mint) so the gateway code cannot be replayed against another 

170 server 

171 dcr_client_id: The ephemeral DCR client the gateway minted at authorize for a 

172 client-forwarded-token server with no caller-supplied client; the callback seals it 

173 into the forwarded authorization code so the token exchange can authenticate with it 

174 while the gateway stores nothing 

175 dcr_client_secret: The minted client's secret, when the upstream issued one 

176 dcr_token_endpoint_auth_method: The token-endpoint auth method the upstream's registration 

177 response granted the minted client, sealed alongside the credentials so the exchange 

178 authenticates the way the upstream expects instead of falling back to the server row's 

179 configured method 

180 

181 Returns: 

182 An encrypted string that encodes all values 

183 """ 

184 state_data: Final = { 

185 "oauth_nonce": oauth_nonce, 

186 "base_url": base_url, 

187 "original_state": original_state, 

188 "code_challenge": code_challenge, 

189 "code_challenge_method": code_challenge_method, 

190 "client_redirect_uri": client_redirect_uri, 

191 "litellm_user_id": litellm_user_id, 

192 "mcp_server_id": mcp_server_id, 

193 "dcr_client_id": dcr_client_id, 

194 "dcr_client_secret": dcr_client_secret, 

195 "dcr_token_endpoint_auth_method": dcr_token_endpoint_auth_method, 

196 } 

197 state_json: Final = json.dumps(state_data, sort_keys=True) 

198 encrypted_state: Final = encrypt_value_helper(state_json) 

199 return encrypted_state 

200 

201 

202def decode_state_hash(encrypted_state: str) -> dict: 

203 """ 

204 Decode an encrypted state to retrieve all OAuth session data. 

205 

206 Args: 

207 encrypted_state: The encrypted string to decode 

208 

209 Returns: 

210 A dict containing base_url, original_state, and optional PKCE parameters 

211 

212 Raises: 

213 Exception: If decryption fails or data is malformed 

214 """ 

215 decrypted_json: Final = decrypt_value_helper(encrypted_state, "oauth_state") 

216 if decrypted_json is None: 216 ↛ 219line 216 didn't jump to line 219 because the condition on line 216 was always true

217 raise ValueError("Failed to decrypt state parameter") 

218 

219 state_data: Final = json.loads(decrypted_json) 

220 return state_data 

221 

222 

223_BRIDGE_AUTH_CODE_PREFIX: Final = "llm_bcode_" 

224 

225 

226class _BridgeAuthorizationCode(BaseModel): 

227 """Authenticated caller and upstream code sealed for bridge or identity-bound per-user OAuth.""" 

228 

229 model_config = ConfigDict(frozen=True) 

230 oauth_nonce: str | None = None 

231 upstream_code: str = Field(min_length=1) 

232 litellm_user_id: str = Field(min_length=1) 

233 mcp_server_id: str = Field(min_length=1) 

234 

235 

236def is_bridge_authorization_code(code: str) -> bool: 

237 """Cheap prefix check that ``code`` is a gateway-sealed bridge authorization code rather than a 

238 raw upstream code, so the token endpoint can route without decrypting.""" 

239 return code.startswith(_BRIDGE_AUTH_CODE_PREFIX) 

240 

241 

242def seal_bridge_authorization_code( 

243 upstream_code: str, 

244 litellm_user_id: str, 

245 mcp_server_id: str, 

246 oauth_nonce: str | None = None, 

247) -> str: 

248 """Seal the upstream authorization code and the SSO-captured litellm user into a gateway 

249 authorization code. The DCR client only echoes this opaque value back at the token endpoint; the 

250 gateway decrypts it there to recover the user (to bind the envelope) and the upstream code (to 

251 exchange with the upstream), so a litellm identity captured in the browser at authorize survives 

252 to the back-channel token call with nothing stored server-side. Encrypted with the repo's 

253 authenticated symmetric helper (the same family the OAuth state uses), so the client can neither 

254 read nor forge it.""" 

255 payload: Final = json.dumps( 

256 { 

257 "upstream_code": upstream_code, 

258 "litellm_user_id": litellm_user_id, 

259 "mcp_server_id": mcp_server_id, 

260 "oauth_nonce": oauth_nonce, 

261 }, 

262 sort_keys=True, 

263 ) 

264 return _BRIDGE_AUTH_CODE_PREFIX + encrypt_value_helper(payload) 

265 

266 

267def open_bridge_authorization_code(code: str) -> _BridgeAuthorizationCode | None: 

268 """Recover the sealed identity and upstream code, or ``None`` when ``code`` is not a gateway 

269 bridge code or does not decrypt / validate. Total over hostile input: a raw upstream code (the 

270 scripted two-header path) returns ``None`` and the caller falls through to the existing 

271 behavior.""" 

272 if not is_bridge_authorization_code(code): 

273 return None 

274 decrypted: Final = decrypt_value_helper( 

275 code[len(_BRIDGE_AUTH_CODE_PREFIX) :], "bridge_authorization_code", return_original_value=False 

276 ) 

277 if not isinstance(decrypted, str): 

278 return None 

279 try: 

280 return _BridgeAuthorizationCode.model_validate_json(decrypted) 

281 except ValidationError: 

282 return None 

283 

284 

285_PASSTHROUGH_AUTH_CODE_PREFIX: Final = "llm_ptcode_" 

286 

287 

288class PassthroughAuthorizationCode(BaseModel): 

289 """The ephemeral DCR client and upstream code the gateway seals into the authorization code it 

290 forwards for a client-forwarded-token server (``true_passthrough`` / ``oauth_delegate``) whose 

291 authorize fell through to gateway-side registration. These modes forbid the gateway from storing 

292 an OAuth client identity, so the minted client survives only inside this sealed value: the 

293 client echoes it back at the token endpoint, where the gateway recovers the client to 

294 authenticate the upstream exchange. ``mcp_server_id`` binds the code to the server it was minted 

295 for so it cannot be spent at another server's token endpoint.""" 

296 

297 model_config = ConfigDict(frozen=True) 

298 upstream_code: str = Field(min_length=1) 

299 client_id: str = Field(min_length=1) 

300 client_secret: str | None = None 

301 token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None 

302 mcp_server_id: str = Field(min_length=1) 

303 

304 

305def seal_passthrough_authorization_code( 

306 upstream_code: str, 

307 client_id: str, 

308 client_secret: str | None, 

309 mcp_server_id: str, 

310 token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None, 

311) -> str: 

312 """Seal the upstream authorization code together with the ephemeral DCR client that authorized 

313 it. Encrypted with the same authenticated symmetric helper as the OAuth state and bridge codes, 

314 so the client can neither read the (possibly confidential) client credentials nor forge a 

315 code.""" 

316 payload: Final = json.dumps( 

317 { 

318 "upstream_code": upstream_code, 

319 "client_id": client_id, 

320 "client_secret": client_secret, 

321 "token_endpoint_auth_method": token_endpoint_auth_method, 

322 "mcp_server_id": mcp_server_id, 

323 }, 

324 sort_keys=True, 

325 ) 

326 return _PASSTHROUGH_AUTH_CODE_PREFIX + encrypt_value_helper(payload) 

327 

328 

329def open_passthrough_authorization_code(code: str) -> PassthroughAuthorizationCode | None: 

330 """Recover the sealed ephemeral client and upstream code, or ``None`` when ``code`` is not a 

331 gateway passthrough code or does not decrypt / validate, so a raw upstream code falls through to 

332 the existing caller-supplied-client behavior.""" 

333 if not code.startswith(_PASSTHROUGH_AUTH_CODE_PREFIX): 

334 return None 

335 decrypted: Final = decrypt_value_helper( 

336 code[len(_PASSTHROUGH_AUTH_CODE_PREFIX) :], "passthrough_authorization_code", return_original_value=False 

337 ) 

338 if not isinstance(decrypted, str): 

339 return None 

340 try: 

341 return PassthroughAuthorizationCode.model_validate_json(decrypted) 

342 except ValidationError: 

343 return None 

344 

345 

346def redeem_passthrough_authorization_code( 

347 code: str | None, mcp_server: MCPServer, code_verifier: str | None 

348) -> PassthroughAuthorizationCode | None: 

349 """The single redemption gate for sealed passthrough codes: a raw or foreign code returns 

350 ``None`` so the caller keeps its existing behavior, while a genuine sealed code must be spent 

351 at the server it was minted for and must carry the PKCE verifier of the S256 flow that minted 

352 it (the mint refuses downgraded flows, so a verifier-less redemption is an interception 

353 attempt, not a legitimate client).""" 

354 if not code: 

355 return None 

356 sealed: Final = open_passthrough_authorization_code(code) 

357 if sealed is None: 

358 return None 

359 if sealed.mcp_server_id != mcp_server.server_id: 

360 raise HTTPException( 

361 status_code=400, 

362 detail="Authorization code was issued for a different MCP server", 

363 ) 

364 if not code_verifier: 

365 raise HTTPException( 

366 status_code=400, 

367 detail="code_verifier is required to redeem this authorization code", 

368 ) 

369 return sealed 

370 

371 

372def _session_cookie_user_id(request: Request) -> str | None: 

373 """The signed-in litellm user for a browser request, or ``None``. Thin wrapper so the 

374 aggregate DCR flow's verbs receive the identity as a plain value instead of parsing 

375 cookies themselves.""" 

376 from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # circular import at module load 

377 _user_id_from_session_cookie, 

378 ) 

379 

380 return _user_id_from_session_cookie(request) 

381 

382 

383def _redirect_to_litellm_login(request: Request) -> RedirectResponse: 

384 """Send an unauthenticated browser through litellm login before the interactive bridge authorize 

385 can capture its identity. The bridge oauth_delegate flow seals the SSO user into the gateway code, 

386 so a session is required; without one there is nothing to bind. A same-origin relative 

387 ``return_to`` (honored by the SSO callback) brings the browser straight back to this authorize 

388 request after login instead of stranding it on the dashboard.""" 

389 base_url: Final = get_request_base_url(request) 

390 return RedirectResponse(f"{base_url}/sso/key/generate?{urlencode({'return_to': relative_request_url(request)})}") 

391 

392 

393# LIT-4197: some upstream authorization servers reject an over-long ``state`` 

394# (the encrypted OAuth session blob routinely exceeds their limit). The upstream 

395# only needs an opaque value it echoes back on ``/callback``, so we forward a 

396# short random handle and keep the encrypted session in a per-flow HttpOnly 

397# cookie bound to that handle. The browser carries the cookie across the 

398# upstream round trip, so the flow stays correct with no server-side session 

399# store (works across proxy replicas, unlike an in-process map). 

400_OAUTH_STATE_COOKIE_PREFIX: Final = "mcp_oauth_state_" 

401_OAUTH_STATE_COOKIE_TTL_SECONDS: Final = 600 

402_OAUTH_STATE_HANDLE_BYTES: Final = 32 

403 

404 

405def _oauth_state_cookie_name(relay_state: str) -> str: 

406 return f"{_OAUTH_STATE_COOKIE_PREFIX}{relay_state}" 

407 

408 

409def _oauth_state_cookie_path_and_secure(request: Request) -> tuple[str, bool]: 

410 parsed: Final = urlparse(get_request_base_url(request)) 

411 return parsed.path or "/", parsed.scheme == "https" 

412 

413 

414def _set_oauth_state_cookie( 

415 response: Response, 

416 request: Request, 

417 relay_state: str, 

418 encoded_state: str, 

419) -> None: 

420 path, secure = _oauth_state_cookie_path_and_secure(request) 

421 response.set_cookie( 

422 key=_oauth_state_cookie_name(relay_state), 

423 value=encoded_state, 

424 max_age=_OAUTH_STATE_COOKIE_TTL_SECONDS, 

425 path=path, 

426 secure=secure, 

427 httponly=True, 

428 samesite="lax", 

429 ) 

430 

431 

432def _resolve_encoded_oauth_state(request: Request, state: str) -> str: 

433 """Return the encrypted OAuth session for a ``/callback`` request. 

434 

435 New flows carry it in a per-flow cookie keyed by the short handle we 

436 forwarded upstream (the IdP echoes that handle back as ``state``). Flows 

437 started before this change - or in flight across a deploy - carry the 

438 encrypted blob directly in ``state``, so fall back to it when the cookie 

439 is absent. 

440 """ 

441 cookie_value: Final = request.cookies.get(_oauth_state_cookie_name(state)) 

442 return cookie_value if cookie_value else state 

443 

444 

445def _clear_oauth_state_cookie(response: Response, request: Request, state: str) -> None: 

446 cookie_name: Final = _oauth_state_cookie_name(state) 

447 if cookie_name not in request.cookies: 447 ↛ 449line 447 didn't jump to line 449 because the condition on line 447 was always true

448 return 

449 path, secure = _oauth_state_cookie_path_and_secure(request) 

450 response.delete_cookie( 

451 key=cookie_name, 

452 path=path, 

453 secure=secure, 

454 httponly=True, 

455 samesite="lax", 

456 ) 

457 

458 

459def _get_validated_client_redirect_uri(request: Request, state_data: Mapping[str, object]) -> str: 

460 """Return a trusted (same-origin, loopback, or ops-allowlisted) 

461 client redirect URI from OAuth state. 

462 """ 

463 redirect_uri: Final = state_data.get("client_redirect_uri") or state_data.get("base_url") 

464 if not redirect_uri or not isinstance(redirect_uri, str): 

465 raise HTTPException(status_code=400, detail="Invalid redirect URI") 

466 validate_trusted_redirect_uri(request, redirect_uri) 

467 return redirect_uri 

468 

469 

470def _append_query_params(url: str, params: dict[str, str]) -> str: 

471 parsed: Final = urlparse(url) 

472 query_params: Final = parse_qsl(parsed.query, keep_blank_values=True) 

473 query_params.extend(params.items()) 

474 return urlunparse(parsed._replace(query=urlencode(query_params))) 

475 

476 

477def _resolve_mcp_server_by_name_or_id(lookup: str, client_ip: str | None) -> MCPServer | None: 

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

479 global_mcp_server_manager, 

480 ) 

481 

482 by_name: Final = global_mcp_server_manager.get_mcp_server_by_name(lookup, client_ip=client_ip) 

483 if by_name is not None: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true

484 return by_name 

485 return global_mcp_server_manager.get_mcp_server_by_id(lookup, client_ip=client_ip) 

486 

487 

488def _resolve_oauth2_server_for_root_endpoints( 

489 client_ip: str | None = None, 

490) -> MCPServer | None: 

491 """ 

492 Resolve the MCP server for root-level OAuth endpoints (no server name in path). 

493 

494 When the MCP SDK hits root-level endpoints like /register, /authorize, /token 

495 without a server name prefix, we try to find the right server automatically. 

496 Returns the server if exactly one OAuth2 server is configured, else None. 

497 """ 

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

499 global_mcp_server_manager, 

500 ) 

501 

502 registry: Final = global_mcp_server_manager.get_filtered_registry(client_ip=client_ip) 

503 oauth2_servers: Final = [s for s in registry.values() if s.auth_type == MCPAuth.oauth2] 

504 if len(oauth2_servers) == 1: 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true

505 return oauth2_servers[0] 

506 return None 

507 

508 

509def _normalize_for_token_comparison(value: object) -> str: 

510 """Stringify ``value`` for token-rule comparison. 

511 

512 Booleans are lower-cased so Python's ``True`` / ``False`` line up with 

513 JSON-style ``"true"`` / ``"false"`` rules from admin config. 

514 """ 

515 if isinstance(value, bool): 

516 return "true" if value else "false" 

517 return str(value) 

518 

519 

520def _validate_token_response( 

521 token_response: Mapping[str, object], 

522 validation_rules: Mapping[str, object], 

523 server_id: str, 

524) -> None: 

525 """Raise HTTPException 403 if any validation rule doesn't match the token response. 

526 

527 Supports dot-notation for nested fields (e.g. ``"team.enterprise_id"`` checks 

528 ``token_response["team"]["enterprise_id"]``). Top-level keys are tried first, 

529 then dot-split traversal. All comparisons are string-coerced so that numeric 

530 values in the response (e.g. ``"org_id": 12345``) match string rules 

531 (``"org_id": "12345"``). Booleans are normalised to JSON-style ``"true"`` / 

532 ``"false"`` so admin rules written as ``{"verified": "true"}`` match upstream 

533 responses of ``{"verified": true}``. 

534 """ 

535 for key, expected in validation_rules.items(): 

536 actual: object | None = token_response.get(key) 

537 # Try dot-notation traversal when top-level lookup returns None 

538 if actual is None and "." in key: 

539 obj: object = token_response 

540 for part in key.split("."): 

541 if isinstance(obj, dict): 

542 obj = obj.get(part) 

543 else: 

544 obj = None 

545 break 

546 actual = obj 

547 # Treat absent fields as a distinct failure from a mismatched value 

548 if actual is None: 

549 raise HTTPException( 

550 status_code=403, 

551 detail={ 

552 "error": "token_validation_failed", 

553 "server_id": server_id, 

554 "field": key, 

555 "message": (f"OAuth token rejected: required field '{key}' is absent"), 

556 }, 

557 ) 

558 if _normalize_for_token_comparison(actual) != _normalize_for_token_comparison(expected): 

559 raise HTTPException( 

560 status_code=403, 

561 detail={ 

562 "error": "token_validation_failed", 

563 "server_id": server_id, 

564 "field": key, 

565 "message": (f"OAuth token rejected: '{key}' = '{actual}', expected '{expected}'"), 

566 }, 

567 ) 

568 

569 

570async def _store_per_user_token_server_side( 

571 server: MCPServer, 

572 user_id: str, 

573 token_response: dict[str, Any], 

574 identity_binding_proof: str | None = None, 

575) -> None: 

576 """Persist the OAuth token server-side and warm the Redis cache. 

577 

578 Called from the token endpoint after a successful code exchange or refresh. 

579 Errors are logged but NOT re-raised — the token is always returned to the 

580 client even when server-side storage fails. 

581 """ 

582 from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: PLC0415 

583 _compute_per_user_token_ttl, 

584 mcp_per_user_token_cache, 

585 ) 

586 from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 

587 

588 access_token: Final[str | None] = token_response.get("access_token") 

589 if not access_token: 

590 return 

591 

592 raw_expires: Final = token_response.get("expires_in") 

593 try: 

594 expires_in: int | None = int(raw_expires) if raw_expires is not None else None 

595 except (TypeError, ValueError): 

596 expires_in = None 

597 

598 refresh_token: Final[str | None] = token_response.get("refresh_token") or None 

599 raw_scope: Final = token_response.get("scope") 

600 scopes: Final[list | None] = raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None 

601 

602 try: 

603 prisma_client: Final = get_prisma_client_or_throw("Database not connected. Cannot store per-user OAuth token.") 

604 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 

605 store_user_oauth_credential, 

606 ) 

607 

608 await store_user_oauth_credential( 

609 prisma_client=prisma_client, 

610 user_id=user_id, 

611 server_id=server.server_id, 

612 access_token=access_token, 

613 refresh_token=refresh_token, 

614 expires_in=expires_in, 

615 scopes=scopes, 

616 identity_binding_proof=identity_binding_proof, 

617 ) 

618 verbose_logger.info( 

619 "_store_per_user_token_server_side: stored token for user=%s server=%s", 

620 user_id, 

621 server.server_id, 

622 ) 

623 except Exception as exc: 

624 verbose_logger.warning( 

625 "_store_per_user_token_server_side: DB storage failed for user=%s server=%s: %s", 

626 user_id, 

627 server.server_id, 

628 exc, 

629 ) 

630 return # Don't warm Redis if DB write failed 

631 

632 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 

633 global_mcp_server_manager, 

634 ) 

635 

636 await global_mcp_server_manager.invalidate_user_oauth_token_cache(user_id, server.server_id) 

637 

638 # Warm the Redis cache so the first subsequent MCP call is a cache hit 

639 ttl: Final = _compute_per_user_token_ttl(server, expires_in) 

640 await mcp_per_user_token_cache.set( 

641 user_id=user_id, 

642 server_id=server.server_id, 

643 access_token=access_token, 

644 ttl=ttl, 

645 identity_binding_proof=identity_binding_proof, 

646 ) 

647 

648 

649def _raise_if_not_oauth2(mcp_server: MCPServer) -> None: 

650 """Reject a server without upstream OAuth from the gateway's authorize/token/register flow. 

651 

652 The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are allowed 

653 through: the caller owns the upstream token, and this relayed flow is how a browser obtains 

654 one against the upstream IdP (the admin UI's browser-only Authorize uses it). The minted 

655 token is upstream-audienced and held by the caller; the gateway persists nothing for these 

656 modes (``_persist_dcr_client_registration`` skips them unconditionally, so even the admin 

657 Authorize path with ``persist_credentials`` enabled writes nothing to the server row). 

658 """ 

659 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load 

660 _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, 

661 ) 

662 

663 if mcp_server.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: 

664 return 

665 raise HTTPException( 

666 status_code=400, 

667 detail={ 

668 "error": "server_not_oauth2", 

669 "message": ( 

670 f"MCP server '{mcp_server.server_name or mcp_server.name}' does not use OAuth " 

671 f"(auth_type={mcp_server.auth_type}). This server does not support the authorization-code " 

672 "flow; it has no client_id, authorize, token, or registration endpoint. " 

673 "Access is controlled by the server's configured auth_type and access groups" 

674 ), 

675 }, 

676 ) 

677 

678 

679def _endpoint_not_configured_detail( 

680 mcp_server: MCPServer, 

681 endpoint_label: str, 

682 manual_remedy: str, 

683 issuer_remedy: str, 

684) -> str: 

685 """The 400 detail for an unresolved OAuth endpoint, naming the likely cause for this server's 

686 shape (LIT-4658): an anchored issuer whose metadata fell short, a configured (possibly 

687 misconfigured) server url whose discovery failed, or no discovery source at all. Kept free of 

688 URLs and issuer values because these endpoints are reachable pre-auth.""" 

689 if mcp_server.issuer_is_anchored: 

690 return ( 

691 f"MCP server {endpoint_label} is not configured. Endpoint discovery anchored on the configured " 

692 f"Issuer (RFC 8414) failed or its metadata did not include this endpoint; check the proxy logs " 

693 f"for 'MCP OAuth' warnings from server load, verify the Issuer, or {manual_remedy}." 

694 ) 

695 if mcp_server.url: 

696 return ( 

697 f"MCP server {endpoint_label} is not configured. OAuth endpoint discovery against the configured " 

698 f"server url did not resolve it; the url may be misconfigured. Check the proxy logs for " 

699 f"'MCP OAuth' warnings from server load, verify the server url, or {manual_remedy}, or " 

700 f"{issuer_remedy}." 

701 ) 

702 return ( 

703 f"MCP server {endpoint_label} is not configured. Servers with no url (OpenAPI spec or stdio) run no " 

704 f"resource discovery, so {manual_remedy}, or {issuer_remedy}." 

705 ) 

706 

707 

708async def _server_with_oauth_endpoints( 

709 mcp_server: MCPServer, 

710 needed_endpoint: Callable[[MCPServer], str | None], 

711) -> MCPServer: 

712 """Join deferred OAuth discovery only when the endpoint this caller needs is still missing. 

713 

714 Admin-entered endpoints live on ``configured_*`` after an anchored issuer empties the 

715 resolved fields. A caller whose needed endpoint already resolves never awaits discovery 

716 and cannot 503 over a leftover pin. A server still missing it joins the deferred task; 

717 no slot is a no-op and the caller 400s. 

718 """ 

719 if needed_endpoint(mcp_server) is not None: 

720 return mcp_server 

721 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load 

722 global_mcp_server_manager, 

723 ) 

724 

725 return await global_mcp_server_manager.ensure_oauth_metadata_discovered(mcp_server) 

726 

727 

728def _raise_unless_oauth2_discovery_server( 

729 mcp_server: MCPServer | None, 

730 mcp_server_name: str | None, 

731 description: str, 

732) -> None: 

733 """404 a NAMED discovery request unless it resolves to an oauth2 or DCR-bridge server. 

734 

735 A named server that is unknown (or hidden from the caller) and one that exists 

736 but is non-oauth2 both return the same 404, so the well-known discovery paths 

737 cannot be used to enumerate non-OAuth server names. Root discovery (no name) is 

738 unaffected, and pass-through servers are resolved by the caller before this runs. 

739 DCR-bridge servers are admitted because they serve the gateway's own authorization 

740 server metadata (the register, authorize, and token relays). 

741 """ 

742 if mcp_server_name is None: 

743 return 

744 if mcp_server is not None and mcp_server.auth_type == MCPAuth.oauth2: 744 ↛ 745line 744 didn't jump to line 745 because the condition on line 744 was never true

745 return 

746 if mcp_server is not None and mcp_server.is_dcr_bridge: 746 ↛ 747line 746 didn't jump to line 747 because the condition on line 746 was never true

747 return 

748 raise HTTPException( 

749 status_code=404, 

750 detail=f"MCP server '{mcp_server_name}' is {description}", 

751 ) 

752 

753 

754def _dcr_bridge_relays_client_registration(mcp_server: MCPServer) -> bool: 

755 """True when a DCR-bridge server relays client registration to the upstream authorization 

756 server instead of short-circuiting to an admin-configured OAuth client. In the relay arm the 

757 upstream holds each client's own registration, so the authorize and token relays pass the 

758 client's ``client_id`` and ``redirect_uri`` through verbatim and the authorization code 

759 returns directly to the client's redirect URI without transiting the gateway. Gateway-side 

760 redirect trust and the ``/callback`` state relay therefore only apply to the short-circuit 

761 arm, where the upstream only knows the gateway's own callback.""" 

762 return mcp_server.is_dcr_bridge and bool(mcp_server.effective_registration_url) and not mcp_server.client_id 

763 

764 

765def _require_s256_pkce( 

766 code_challenge: str | None, 

767 code_challenge_method: str | None, 

768) -> tuple[str, str]: 

769 """DCR-bridge servers serve unauthenticated public OAuth clients, so the PKCE downgrade 

770 paths (no challenge, or a non-S256 method; RFC 7636 defaults a missing method to ``plain``) 

771 are rejected at the gateway instead of relying on upstream enforcement. Returns the 

772 validated pair so callers get non-optional values.""" 

773 if code_challenge and code_challenge_method == "S256": 

774 return code_challenge, code_challenge_method 

775 raise HTTPException( 

776 status_code=400, 

777 detail=( 

778 "This server requires PKCE: send code_challenge with " 

779 "code_challenge_method=S256 on the authorization request" 

780 ), 

781 ) 

782 

783 

784def _redirect_to_upstream_authorize( 

785 *, 

786 mcp_server: MCPServer, 

787 client_id: str, 

788 redirect_uri: str, 

789 state: str, 

790 code_challenge: str, 

791 code_challenge_method: str, 

792 response_type: str | None, 

793 scope: str | None, 

794) -> RedirectResponse: 

795 """The bridge relay arm's authorize redirect: every client-supplied parameter passes through 

796 to the upstream authorize endpoint verbatim, no relay state cookie is set, and the upstream 

797 enforces its own registered redirect binding for the client.""" 

798 scope_value: Final = scope or (" ".join(mcp_server.scopes) if mcp_server.scopes else None) 

799 upstream_resource: Final = resolve_upstream_resource(mcp_server) 

800 passthrough_params: Final = { 

801 "client_id": client_id, 

802 "redirect_uri": redirect_uri, 

803 "state": state, 

804 "response_type": response_type or "code", 

805 "code_challenge": code_challenge, 

806 "code_challenge_method": code_challenge_method, 

807 **({"scope": scope_value} if scope_value else {}), 

808 **({"resource": upstream_resource} if upstream_resource else {}), 

809 } 

810 parsed_auth_url: Final = urlparse(mcp_server.effective_authorization_url or "") 

811 merged_params: Final = {**dict(parse_qsl(parsed_auth_url.query)), **passthrough_params} 

812 return RedirectResponse(urlunparse(parsed_auth_url._replace(query=urlencode(merged_params)))) 

813 

814 

815def _bridge_access_denied_redirect(redirect_uri: str, state: str, mcp_server: MCPServer) -> RedirectResponse: 

816 """RFC 6749 section 4.1.2.1 denial for the interactive bridge authorize, delivered to the 

817 already-validated client redirect_uri so a DCR client surfaces the failure at connect time.""" 

818 server_label: Final = mcp_server.alias or mcp_server.server_name or mcp_server.server_id 

819 params: Final = { 

820 "error": "access_denied", 

821 "error_description": ( 

822 f"the signed-in user has no access to MCP server '{server_label}' on this gateway; " 

823 "grant it through a team or user object permission, or mark the server allow_all_keys" 

824 ), 

825 **({"state": state} if state else {}), 

826 } 

827 return RedirectResponse(_append_query_params(redirect_uri, params), status_code=302) 

828 

829 

830async def _user_can_reach_mcp_server(user_id: str, server_id: str) -> bool: 

831 from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( 

832 MCPRequestHandler, 

833 ) 

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

835 global_mcp_server_manager, 

836 ) 

837 

838 try: 

839 admitted: Final = await MCPRequestHandler.reload_admitted_user(user_id) 

840 except HTTPException as exc: 

841 if exc.status_code >= 500: 

842 raise 

843 return False 

844 return server_id in await global_mcp_server_manager.get_allowed_mcp_servers(admitted) 

845 

846 

847async def _resolve_oauth_authorization_user( 

848 request: Request, 

849 mcp_server: MCPServer, 

850 redirect_uri: str, 

851 state: str, 

852 enforce_binding: bool, 

853) -> str | RedirectResponse: 

854 """Resolve the authorization subject without replacing denied credentials with cookie grants.""" 

855 from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # proxy import cycle 

856 _user_id_from_session_cookie, 

857 ) 

858 

859 use_gateway_credential: Final = enforce_binding and await oauth_authorization_uses_gateway_credential(request) 

860 request_user_id: Final = ( 

861 await authorize_oauth_credential_request(request, mcp_server.server_id) if use_gateway_credential else None 

862 ) 

863 if use_gateway_credential and request_user_id is None: 

864 return _bridge_access_denied_redirect(redirect_uri, state, mcp_server) 

865 user_id: Final = request_user_id or _user_id_from_session_cookie(request) 

866 if user_id is None: 

867 return _redirect_to_litellm_login(request) 

868 if not await _user_can_reach_mcp_server(user_id, mcp_server.server_id): 

869 return _bridge_access_denied_redirect(redirect_uri, state, mcp_server) 

870 return user_id 

871 

872 

873async def authorize_with_server( 

874 request: Request, 

875 mcp_server: MCPServer, 

876 client_id: str, 

877 redirect_uri: str, 

878 state: str = "", 

879 code_challenge: str | None = None, 

880 code_challenge_method: str | None = None, 

881 response_type: str | None = None, 

882 scope: str | None = None, 

883 ephemeral_dcr_client: "EphemeralDcrClient | None" = None, 

884): 

885 _raise_if_not_oauth2(mcp_server) 

886 resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _register_flow_needed_endpoint) 

887 if resolved_server.effective_authorization_url is None: 

888 raise HTTPException( 

889 status_code=400, 

890 detail=_endpoint_not_configured_detail( 

891 resolved_server, 

892 "authorization url", 

893 "set Authorization URL and Token URL manually", 

894 "set Issuer to discover them from the identity provider (RFC 8414)", 

895 ), 

896 ) 

897 

898 binding: Final = resolved_server.oauth_identity_binding 

899 enforce_binding: Final = binding is not None and binding.mode == "enforce" 

900 if enforce_binding: 

901 _require_s256_pkce(code_challenge, code_challenge_method) 

902 

903 if resolved_server.is_dcr_bridge: 

904 # Enforce S256 PKCE on both bridge arms. The relay arm forwards the validated, 

905 # now-non-optional pair to the upstream authorize; the short-circuit arm keeps 

906 # calling this for its enforcement side effect, then falls through to the gateway 

907 # /callback flow below, which reads the original code_challenge names. 

908 bridge_challenge, bridge_method = _require_s256_pkce(code_challenge, code_challenge_method) 

909 # A gateway-minted ephemeral client is registered against {base}/callback, so its 

910 # flow must run the short-circuit arm; the relay arm is only for clients that 

911 # registered themselves through the front door and hold their own redirect binding. 

912 if _dcr_bridge_relays_client_registration(resolved_server) and ephemeral_dcr_client is None: 

913 return _redirect_to_upstream_authorize( 

914 mcp_server=resolved_server, 

915 client_id=client_id, 

916 redirect_uri=redirect_uri, 

917 state=state, 

918 code_challenge=bridge_challenge, 

919 code_challenge_method=bridge_method, 

920 response_type=response_type, 

921 scope=scope, 

922 ) 

923 

924 # Trusted redirect_uri: same-origin, loopback, or ops-allowlisted. 

925 # The URI is encrypted into the OAuth state and decoded on 

926 # /callback to redirect the user back; a non-trusted URI would be 

927 # an open-redirect + code-theft primitive (VERIA-57 root cause B). 

928 validate_trusted_redirect_uri(request, redirect_uri) 

929 parsed: Final = urlparse(redirect_uri) 

930 base_url: Final = urlunparse(parsed._replace(query="")) 

931 request_base_url: Final = get_request_base_url(request) 

932 

933 # Seal the authenticated caller into state so the token exchange cannot select another credential owner. 

934 litellm_user_id: str | None = None 

935 if enforce_binding or (resolved_server.is_dcr_bridge and resolved_server.is_oauth_delegate): 

936 subject: Final = await _resolve_oauth_authorization_user( 

937 request, resolved_server, redirect_uri, state, enforce_binding 

938 ) 

939 if isinstance(subject, RedirectResponse): 

940 return subject 

941 litellm_user_id = subject 

942 

943 oauth_nonce: Final = secrets.token_urlsafe(32) if enforce_binding else None 

944 encoded_state: Final = encode_state_with_base_url( 

945 base_url=base_url, 

946 original_state=state, 

947 oauth_nonce=oauth_nonce, 

948 code_challenge=code_challenge, 

949 code_challenge_method=code_challenge_method, 

950 client_redirect_uri=redirect_uri, 

951 litellm_user_id=litellm_user_id, 

952 mcp_server_id=resolved_server.server_id if (litellm_user_id or ephemeral_dcr_client) else None, 

953 dcr_client_id=ephemeral_dcr_client.client_id if ephemeral_dcr_client else None, 

954 dcr_client_secret=ephemeral_dcr_client.client_secret if ephemeral_dcr_client else None, 

955 dcr_token_endpoint_auth_method=ephemeral_dcr_client.token_endpoint_auth_method 

956 if ephemeral_dcr_client 

957 else None, 

958 ) 

959 relay_state: Final = secrets.token_urlsafe(_OAUTH_STATE_HANDLE_BYTES) 

960 

961 params: Final = { 

962 "client_id": resolved_server.client_id if resolved_server.client_id else client_id, 

963 "redirect_uri": f"{request_base_url}/callback", 

964 "state": relay_state, 

965 "response_type": response_type or "code", 

966 } 

967 if oauth_nonce: 

968 params["nonce"] = oauth_nonce 

969 if scope: 

970 params["scope"] = scope 

971 elif resolved_server.scopes: 

972 params["scope"] = " ".join(resolved_server.scopes) 

973 

974 if enforce_binding and "openid" not in params.get("scope", "").split(): 

975 params["scope"] = f"openid {params.get('scope', '')}".strip() 

976 

977 if code_challenge: 

978 params["code_challenge"] = code_challenge 

979 if code_challenge_method: 

980 params["code_challenge_method"] = code_challenge_method 

981 

982 upstream_resource: Final = resolve_upstream_resource(resolved_server) 

983 if upstream_resource: 

984 params["resource"] = upstream_resource 

985 

986 parsed_auth_url: Final = urlparse(resolved_server.effective_authorization_url) 

987 existing_params: Final = dict(parse_qsl(parsed_auth_url.query)) 

988 existing_params.update(params) 

989 final_url: Final = urlunparse(parsed_auth_url._replace(query=urlencode(existing_params))) 

990 response: Final = RedirectResponse(final_url) 

991 _set_oauth_state_cookie(response, request, relay_state, encoded_state) 

992 return response 

993 

994 

995def _token_credential_source(mcp_server: MCPServer) -> CredentialSource: 

996 """Mirrors the resolved-client rule in :func:`exchange_token_with_server`: when the server has a 

997 stored client_id the gateway presents its own credentials upstream, so a credential rejection is 

998 the operator's fault, not the caller's.""" 

999 return "gateway_stored" if mcp_server.client_id else "caller_supplied" 

1000 

1001 

1002async def exchange_token_with_server( 

1003 request: Request, 

1004 mcp_server: MCPServer, 

1005 grant_type: str, 

1006 code: str | None, 

1007 redirect_uri: str | None, 

1008 client_id: str, 

1009 client_secret: str | None, 

1010 code_verifier: str | None, 

1011 refresh_token: str | None = None, 

1012 scope: str | None = None, 

1013 client_token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None, 

1014): 

1015 _raise_if_not_oauth2(mcp_server) 

1016 if grant_type not in ("authorization_code", "refresh_token"): 

1017 raise HTTPException(status_code=400, detail="Unsupported grant_type") 

1018 

1019 resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _token_flow_needed_endpoint) 

1020 token_url: Final = resolved_server.effective_token_url 

1021 if token_url is None: 

1022 raise HTTPException( 

1023 status_code=400, 

1024 detail=_endpoint_not_configured_detail( 

1025 resolved_server, 

1026 "token url", 

1027 "set Token URL manually", 

1028 "set Issuer to discover it from the identity provider (RFC 8414)", 

1029 ), 

1030 ) 

1031 

1032 # The id, secret, and token-endpoint auth method must come from the same source. When the 

1033 # server-side client_id wins, falling back to the caller's secret pairs the persisted client 

1034 # with a foreign secret; the register short-circuit hands clients a placeholder secret 

1035 # ("dummy"), so a re-auth against a persisted public PKCE client (no stored secret) would send 

1036 # that placeholder and the IdP 401s. Symmetrically, a caller-side client (an ephemeral mint 

1037 # recovered from a sealed code) must authenticate the way its own registration was granted, 

1038 # not the way the server row is configured; callers that carry no method keep the row's method 

1039 # as before. 

1040 resolved_client_id: Final = resolved_server.client_id if resolved_server.client_id else client_id 

1041 resolved_client_secret: Final = resolved_server.client_secret if resolved_server.client_id else client_secret 

1042 resolved_auth_method: Final = ( 

1043 resolved_server.token_endpoint_auth_method 

1044 if resolved_server.client_id 

1045 else (client_token_endpoint_auth_method or resolved_server.token_endpoint_auth_method) 

1046 ) 

1047 try: 

1048 token_request: Final = build_upstream_oauth2_token_request( 

1049 resolved_server, 

1050 auth_method=resolved_auth_method, 

1051 client_id=resolved_client_id, 

1052 client_secret=resolved_client_secret, 

1053 ) 

1054 except TokenEndpointAuthConfigError as exc: 

1055 raise HTTPException(status_code=400, detail=str(exc)) from exc 

1056 

1057 request_user_id: Final = ( 

1058 await _extract_user_id_from_request(request) 

1059 if resolved_server.needs_user_oauth_token or resolved_server.oauth_identity_binding is not None 

1060 else None 

1061 ) 

1062 

1063 bridge_identity: _BridgeAuthorizationCode | None = None 

1064 bridge_mint_ready: _BridgeMintReady | None = None 

1065 bridge_upstream_refresh: SecretStr | None = None 

1066 bridge_upstream_scope: str | None = None 

1067 refresh_request_scope: str | None = None 

1068 is_bridge: Final = resolved_server.is_oauth_delegate and resolved_server.is_dcr_bridge 

1069 

1070 if grant_type == "refresh_token": 

1071 # Phase 1 for a bridge refresh: open the client's refresh envelope, re-validate the sealed 

1072 # identity, and unwrap the real upstream refresh token BEFORE building token_data, so the exchange 

1073 # sends the upstream token and never the envelope. A failure returns without touching the upstream. 

1074 if is_bridge: 

1075 prepared_refresh: Final = await _prepare_bridge_refresh(resolved_server, refresh_token) 

1076 if not isinstance(prepared_refresh, _BridgeRefreshReady): 

1077 return _bridge_mint_error_response(prepared_refresh) 

1078 bridge_mint_ready = prepared_refresh.ready 

1079 bridge_upstream_refresh = prepared_refresh.upstream_refresh_token 

1080 bridge_upstream_scope = prepared_refresh.upstream_scope 

1081 # A bridge server sends the unwrapped upstream refresh token recovered from the client's refresh 

1082 # envelope above; every other server sends the client's own refresh token verbatim. 

1083 upstream_refresh_token: Final = ( 

1084 bridge_upstream_refresh.get_secret_value() if bridge_upstream_refresh is not None else refresh_token 

1085 ) 

1086 if not upstream_refresh_token: 

1087 raise HTTPException( 

1088 status_code=400, 

1089 detail="refresh_token is required for refresh_token grant", 

1090 ) 

1091 token_data: dict = { 

1092 "grant_type": "refresh_token", 

1093 "refresh_token": upstream_refresh_token, 

1094 **token_request.body, 

1095 } 

1096 refresh_request_scope = scope or bridge_upstream_scope 

1097 if refresh_request_scope: 

1098 token_data["scope"] = refresh_request_scope 

1099 refresh_ownership = ( # rebind-ok: grant-specific branches assign one ownership value 

1100 RefreshOwnershipProven() 

1101 if bridge_upstream_refresh is not None 

1102 else RefreshTokenPresented(upstream_refresh_token) 

1103 ) 

1104 else: 

1105 refresh_ownership = None # rebind-ok: grant-specific branches assign one ownership value 

1106 if not code: 

1107 raise HTTPException( 

1108 status_code=400, 

1109 detail="code is required for authorization_code grant", 

1110 ) 

1111 # Interactive dcr_bridge oauth_delegate: the client presents the gateway authorization code the 

1112 # callback sealed. Recover the SSO user and the real upstream code from it; the upstream exchange 

1113 # below uses the upstream code, and the mint binds the envelope to the recovered user. Bind the 

1114 # sealed server to this request so a code minted for one bridge server cannot be spent at another. 

1115 # A raw upstream code (scripted path) opens to None and the code is used as-is. 

1116 bridge_identity = open_bridge_authorization_code(code) 

1117 if bridge_identity is not None: 

1118 if bridge_identity.mcp_server_id != resolved_server.server_id: 

1119 raise HTTPException( 

1120 status_code=400, 

1121 detail="Authorization code was issued for a different MCP server", 

1122 ) 

1123 code = bridge_identity.upstream_code 

1124 binding: Final = resolved_server.oauth_identity_binding 

1125 if binding is not None and binding.mode == "enforce": 

1126 if bridge_identity is None or not bridge_identity.oauth_nonce: 

1127 raise HTTPException(status_code=403, detail={"error": "oauth_identity_binding_failed"}) 

1128 if request_user_id is not None and request_user_id != bridge_identity.litellm_user_id: 

1129 raise HTTPException(status_code=403, detail={"error": "oauth_principal_mismatch"}) 

1130 if not code_verifier: 

1131 raise HTTPException(status_code=403, detail={"error": "oauth_identity_binding_failed"}) 

1132 bridge_token_relay: Final = _dcr_bridge_relays_client_registration(resolved_server) 

1133 if bridge_token_relay and not redirect_uri: 

1134 raise HTTPException( 

1135 status_code=400, 

1136 detail=( 

1137 "redirect_uri is required for the authorization_code grant on this server; " 

1138 "send the same redirect_uri used on the authorization request" 

1139 ), 

1140 ) 

1141 proxy_base_url: Final = get_request_base_url(request) 

1142 resolved_redirect_uri: Final = redirect_uri if bridge_token_relay else f"{proxy_base_url}/callback" 

1143 token_data = { 

1144 "grant_type": "authorization_code", 

1145 "code": code, 

1146 "redirect_uri": resolved_redirect_uri, 

1147 **token_request.body, 

1148 } 

1149 if code_verifier: 

1150 token_data["code_verifier"] = code_verifier 

1151 # Phase 1 for a bridge authorization_code mint: resolve identity (the SSO user recovered above, or 

1152 # the presented litellm key) and the envelope keys BEFORE the exchange consumes the single-use code. 

1153 if is_bridge: 

1154 prepared: Final = await _prepare_bridge_mint(request, resolved_server, bridge_identity) 

1155 if not isinstance(prepared, _BridgeMintReady): 

1156 return _bridge_mint_error_response(prepared) 

1157 bridge_mint_ready = prepared 

1158 

1159 refresh_binding: Final = resolved_server.oauth_identity_binding 

1160 if grant_type == "refresh_token" and refresh_binding is not None and refresh_binding.mode == "enforce": 

1161 await enforce_oauth_identity_binding( 

1162 server=resolved_server, 

1163 token_response={}, 

1164 litellm_user_id=request_user_id, 

1165 grant_type=grant_type, 

1166 refresh_ownership=refresh_ownership, 

1167 ) 

1168 

1169 async_client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) 

1170 try: 

1171 response: Final = await async_client.post( 

1172 token_url, 

1173 headers={"Accept": "application/json", **token_request.headers}, 

1174 data=token_data, 

1175 ) 

1176 response.raise_for_status() 

1177 except httpx.HTTPStatusError as exc: 

1178 fault: Final = classify_upstream_token_rejection( 

1179 exc.response, 

1180 credential_source=_token_credential_source(resolved_server), 

1181 log_context=resolved_server.server_id, 

1182 ) 

1183 upstream_rejected_bridge_refresh: Final = ( 

1184 is_bridge 

1185 and grant_type == "refresh_token" 

1186 and isinstance(fault, CallerRejected) 

1187 and fault.code == "invalid_grant" 

1188 ) 

1189 if upstream_rejected_bridge_refresh: 

1190 verbose_logger.info( 

1191 "bridge refresh: the upstream rejected the sealed refresh token for server=%s with " 

1192 "invalid_grant (revoked or expired at the IdP); returning invalid_grant so the client " 

1193 "re-runs authorization_code rather than an opaque upstream error", 

1194 resolved_server.server_id, 

1195 ) 

1196 return _bridge_mint_error_response("invalid_refresh") 

1197 return render_token_fault(fault) 

1198 token_response = response.json() 

1199 

1200 # Validate token response against server-configured rules before any storage. 

1201 # This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc. 

1202 if resolved_server.token_validation and isinstance(resolved_server.token_validation, dict): 

1203 _validate_token_response( 

1204 token_response=token_response, 

1205 validation_rules=resolved_server.token_validation, 

1206 server_id=resolved_server.server_id, 

1207 ) 

1208 

1209 # Bind the exchanged token to the LiteLLM caller BEFORE it is returned, stored, or cached, so a 

1210 # token minted for a different upstream principal never becomes usable under the caller's user_id. 

1211 resolved_user_id: Final = bridge_identity.litellm_user_id if bridge_identity else request_user_id 

1212 binding_proof: Final = ( 

1213 await enforce_oauth_identity_binding( 

1214 server=resolved_server, 

1215 token_response=token_response, 

1216 litellm_user_id=resolved_user_id, 

1217 grant_type=grant_type, 

1218 refresh_ownership=refresh_ownership, 

1219 expected_nonce=bridge_identity.oauth_nonce if bridge_identity else None, 

1220 ) 

1221 if isinstance(token_response, dict) 

1222 else None 

1223 ) 

1224 

1225 # Store server-side when the server is configured for per-user OAuth and 

1226 # the calling client has provided a valid LiteLLM identity. 

1227 # Errors are non-fatal: the token is still returned to the client. 

1228 if resolved_server.needs_user_oauth_token: 

1229 user_id: Final = resolved_user_id 

1230 if user_id: 

1231 try: 

1232 # Identity binding above must retain the verified caller even when a write is 

1233 # denied. Authorize persistence separately, immediately before its side effect. 

1234 from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler 

1235 

1236 # A sealed code delegates a verified user for this authorized server. Raw 

1237 # request credentials retain their own JWT/key restrictions during resolution. 

1238 can_store: Final = ( 

1239 await can_store_oauth_credential( 

1240 request, await MCPRequestHandler.reload_admitted_user(user_id), resolved_server.server_id 

1241 ) 

1242 if bridge_identity is not None 

1243 else await authorize_oauth_credential_request(request, resolved_server.server_id) == user_id 

1244 ) 

1245 if can_store: 

1246 await _store_per_user_token_server_side( 

1247 server=resolved_server, 

1248 user_id=user_id, 

1249 token_response=token_response, 

1250 identity_binding_proof=binding_proof, 

1251 ) 

1252 else: 

1253 verbose_logger.warning( 

1254 "OAuth credential storage not authorized for user=%s server=%s", 

1255 user_id, 

1256 resolved_server.server_id, 

1257 ) 

1258 except Exception as exc: 

1259 verbose_logger.warning( 

1260 "exchange_token_with_server: server-side storage failed for user=%s server=%s: %s", 

1261 user_id, 

1262 resolved_server.server_id, 

1263 exc, 

1264 ) 

1265 else: 

1266 verbose_logger.warning( 

1267 "exchange_token_with_server: could not resolve a LiteLLM user_id for the request, " 

1268 "so the per-user token for server=%s was NOT stored. The authorization_code egress " 

1269 "requires the stored token, so the client will be challenged with 401 on reconnect. " 

1270 "Ensure the request carries a valid LiteLLM key or enabled JWT identity " 

1271 "(x-litellm-api-key or Authorization), " 

1272 "or store it via POST /v1/mcp/server/{id}/oauth-user-credential.", 

1273 resolved_server.server_id, 

1274 ) 

1275 

1276 # A DCR-bridge oauth_delegate server hands the client a gateway-bound envelope (identity plus the 

1277 # upstream token) instead of the raw upstream token, so the one bearer both admits the caller and 

1278 # forwards the upstream credential. Only this mode mints; every other server returns the raw token. 

1279 if bridge_mint_ready is not None: 

1280 if refresh_request_scope and isinstance(token_response, dict) and not token_response.get("scope"): 

1281 token_response = {**token_response, "scope": refresh_request_scope} 

1282 # Phase 3: seal the upstream grant into the client-held envelope; failures map through the same 

1283 # OAuth-shaped response as the phase-1 preconditions. 

1284 minted: Final = _finish_bridge_mint( 

1285 bridge_mint_ready, resolved_server, token_response, datetime.now(timezone.utc) 

1286 ) 

1287 return minted if isinstance(minted, JSONResponse) else _bridge_mint_error_response(minted) 

1288 

1289 raw_access_token: Final = token_response.get("access_token") if isinstance(token_response, dict) else None 

1290 if not isinstance(raw_access_token, str) or not raw_access_token: 

1291 return render_token_fault(UpstreamProtocolFault(note="the upstream token response has no usable access_token")) 

1292 

1293 result: Final = { 

1294 "access_token": raw_access_token, 

1295 "token_type": token_response.get("token_type", "Bearer"), 

1296 } 

1297 

1298 if token_response.get("expires_in") is not None: 

1299 result["expires_in"] = token_response["expires_in"] 

1300 if token_response.get("refresh_token"): 

1301 result["refresh_token"] = token_response["refresh_token"] 

1302 if token_response.get("scope"): 

1303 result["scope"] = token_response["scope"] 

1304 

1305 # RFC 6749 §5.1: token responses must not be cached. 

1306 return JSONResponse(result, headers=TOKEN_NO_CACHE_HEADERS) 

1307 

1308 

1309class _DcrClientRegistration(BaseModel): 

1310 """RFC 7591 dynamic client registration response, narrowed to the fields the gateway 

1311 must persist to authenticate later token-endpoint calls. Extra members are ignored.""" 

1312 

1313 client_id: str 

1314 client_secret: str | None = None 

1315 token_endpoint_auth_method: str | None = None 

1316 

1317 

1318class _PersistedDcrCredentials(BaseModel): 

1319 client_id: str | None = None 

1320 client_secret: str | None = None 

1321 token_endpoint_auth_method: str | None = None 

1322 redirect_uris: list[str] | None = None 

1323 

1324 

1325def _redirect_uri_not_registered(credentials: _PersistedDcrCredentials, current_redirect_uri: str) -> bool: 

1326 """Whether a persisted DCR client is positively known NOT to cover the current callback. 

1327 

1328 A DCR client is bound to the redirect_uris it was registered with; if the proxy's 

1329 resolved public origin has since changed, every authorize built for it will be 

1330 rejected by the IdP. Clients persisted before ``redirect_uris`` was recorded (and 

1331 admin-configured clients, which never get a recording) return False so they are 

1332 grandfathered rather than re-registered, because re-minting a client_id orphans 

1333 every user's refresh tokens for that server.""" 

1334 recorded: Final = credentials.redirect_uris 

1335 if not recorded: 

1336 return False 

1337 return current_redirect_uri not in recorded 

1338 

1339 

1340def _get_persisted_dcr_credentials(credentials: object) -> _PersistedDcrCredentials | None: 

1341 if not credentials: 

1342 return None 

1343 try: 

1344 return ( 

1345 _PersistedDcrCredentials.model_validate_json(credentials) 

1346 if isinstance(credentials, str) 

1347 else _PersistedDcrCredentials.model_validate(credentials) 

1348 ) 

1349 except ValidationError: 

1350 return None 

1351 

1352 

1353def _decrypt_persisted_dcr_credential(value: str | None, key: str) -> str | None: 

1354 if value is None: 

1355 return None 

1356 return decrypt_value_helper( 

1357 value=value, 

1358 key=key, 

1359 exception_type="debug", 

1360 return_original_value=True, 

1361 ) 

1362 

1363 

1364def _apply_persisted_dcr_credentials(mcp_server: MCPServer, credentials: _PersistedDcrCredentials) -> bool: 

1365 client_id: Final = _decrypt_persisted_dcr_credential(credentials.client_id, "client_id") 

1366 if not client_id: 

1367 return False 

1368 mcp_server.client_id = client_id 

1369 mcp_server.client_secret = _decrypt_persisted_dcr_credential(credentials.client_secret, "client_secret") 

1370 mcp_server.token_endpoint_auth_method = credentials.token_endpoint_auth_method 

1371 return True 

1372 

1373 

1374async def _load_store_dcr_credentials(mcp_server: MCPServer) -> _PersistedDcrCredentials | None: 

1375 """DCR client persisted in the server-scoped OAuth-client store for a config-declared server 

1376 (which has no LiteLLM_MCPServerTable row). Returns None when the store has no usable client_id 

1377 or the DB is unreachable.""" 

1378 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # avoids circular import 

1379 get_mcp_server_oauth_client_credentials, 

1380 ) 

1381 from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 # avoids circular import 

1382 

1383 try: 

1384 prisma_client = get_prisma_client_or_throw("Database not connected. Cannot read MCP OAuth client registration.") 

1385 blob: Final = await get_mcp_server_oauth_client_credentials( 

1386 prisma_client=prisma_client, server_id=mcp_server.server_id 

1387 ) 

1388 except Exception as exc: # noqa: BLE001 # best-effort read; DB may be unreachable 

1389 verbose_logger.debug( 

1390 "register_client_with_server: failed to read stored DCR client for server_id=%s: %s", 

1391 mcp_server.server_id, 

1392 exc, 

1393 ) 

1394 return None 

1395 

1396 credentials: Final = _get_persisted_dcr_credentials(blob) 

1397 if credentials is None or not credentials.client_id: 

1398 return None 

1399 return credentials 

1400 

1401 

1402async def hydrate_config_server_dcr_client(mcp_server: MCPServer) -> bool: 

1403 """Overlay a config-declared server's persisted DCR client onto its in-memory object so token 

1404 refresh can authenticate. Config.yaml servers have no LiteLLM_MCPServerTable row, so their 

1405 minted client lives in the server-scoped store; without this overlay the in-memory server 

1406 carries no client_id after a restart. An explicit client_id set in config.yaml wins and is never 

1407 overwritten by a persisted store client.""" 

1408 if mcp_server.client_id: 

1409 return False 

1410 credentials: Final = await _load_store_dcr_credentials(mcp_server) 

1411 if credentials is None: 

1412 return False 

1413 return _apply_persisted_dcr_credentials(mcp_server, credentials) 

1414 

1415 

1416async def _resolve_persisted_dcr_client( 

1417 mcp_server: MCPServer, 

1418) -> tuple[Optional["LiteLLM_MCPServerTable"], _PersistedDcrCredentials | None]: 

1419 """Resolve a server's persisted DCR client using the same two-level rule the write path uses, so 

1420 read and write always agree. First, whether the server HAS a LiteLLM_MCPServerTable row: a row is 

1421 always resolved to that row and the store is never consulted for a server that has a row, so a 

1422 caller-chosen server_id colliding with a config-declared server cannot inherit that config 

1423 server's client, and a row that exists but carries no usable client_id yields (row, None) rather 

1424 than a store fallback. Second, among rowless servers: a config-declared server keeps its client in 

1425 the server-scoped store, while a rowless non-config server is a throwaway temp/session server with 

1426 no persisted client. Returns (row_or_None, credentials_or_None); the row is only needed by the 

1427 reuse path to refresh the registry for a DB-declared server.""" 

1428 from litellm.proxy._experimental.mcp_server.db import get_mcp_server # noqa: PLC0415 # avoids circular import 

1429 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # avoids circular import 

1430 global_mcp_server_manager, 

1431 ) 

1432 from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 # avoids circular import 

1433 

1434 try: 

1435 prisma_client = get_prisma_client_or_throw("Database not connected. Cannot read MCP OAuth client registration.") 

1436 row: Final = await get_mcp_server(prisma_client=prisma_client, server_id=mcp_server.server_id) 

1437 except Exception as exc: # noqa: BLE001 # best-effort read; DB may be unreachable 

1438 verbose_logger.debug( 

1439 "register_client_with_server: failed to read persisted DCR client for server_id=%s: %s", 

1440 mcp_server.server_id, 

1441 exc, 

1442 ) 

1443 return None, None 

1444 

1445 if row is not None: 

1446 credentials: Final = _get_persisted_dcr_credentials(row.credentials) 

1447 if credentials is not None and credentials.client_id: 

1448 return row, credentials 

1449 return row, None 

1450 if global_mcp_server_manager.is_config_declared_server(mcp_server.server_id): 

1451 return None, await _load_store_dcr_credentials(mcp_server) 

1452 return None, None 

1453 

1454 

1455async def _reuse_persisted_dcr_client_if_available( 

1456 mcp_server: MCPServer, current_redirect_uri: str | None = None 

1457) -> bool: 

1458 persisted_mcp_server, credentials = await _resolve_persisted_dcr_client(mcp_server) 

1459 if credentials is None: 

1460 return False 

1461 if current_redirect_uri is not None and _redirect_uri_not_registered(credentials, current_redirect_uri): 

1462 verbose_logger.debug( 

1463 "register_client_with_server: not reusing persisted DCR client for server_id=%s; its registered " 

1464 "redirect_uris=%s do not include the current callback %s. The operator-facing warning for this " 

1465 "re-registration event is emitted once by _persisted_dcr_redirect_uri_is_stale.", 

1466 mcp_server.server_id, 

1467 credentials.redirect_uris, 

1468 current_redirect_uri, 

1469 ) 

1470 return False 

1471 if not _apply_persisted_dcr_credentials(mcp_server, credentials): 

1472 return False 

1473 

1474 if persisted_mcp_server is not None: 

1475 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # avoids circular import 

1476 global_mcp_server_manager, 

1477 ) 

1478 

1479 try: 

1480 await global_mcp_server_manager.update_server(persisted_mcp_server) 

1481 except Exception as exc: # noqa: BLE001 # best-effort registry refresh 

1482 verbose_logger.warning( 

1483 "register_client_with_server: failed to refresh persisted DCR client registration for server_id=%s: %s", 

1484 mcp_server.server_id, 

1485 exc, 

1486 ) 

1487 return bool(mcp_server.client_id) 

1488 

1489 

1490async def _persisted_dcr_redirect_uri_is_stale(mcp_server: MCPServer, current_redirect_uri: str) -> bool: 

1491 """Whether the server's persisted DCR client is bound to redirect_uris that no longer 

1492 cover the current proxy callback, meaning authorize is guaranteed to fail IdP-side. 

1493 

1494 Consulted when the in-memory server already carries a hydrated client_id, which 

1495 otherwise short-circuits registration before any redirect check can run. Servers 

1496 without a persisted DCR recording (admin-configured client_id, or registered before 

1497 redirect_uris were recorded) are never reported stale.""" 

1498 _, credentials = await _resolve_persisted_dcr_client(mcp_server) 

1499 if credentials is None: 

1500 return False 

1501 if not _redirect_uri_not_registered(credentials, current_redirect_uri): 

1502 return False 

1503 verbose_logger.warning( 

1504 "register_client_with_server: persisted DCR client for server_id=%s is registered with redirect_uris=%s " 

1505 "which do not include the current callback %s (proxy origin changed); registering a replacement client. " 

1506 "Users previously signed in to this server will need to re-authenticate.", 

1507 mcp_server.server_id, 

1508 credentials.redirect_uris, 

1509 current_redirect_uri, 

1510 ) 

1511 return True 

1512 

1513 

1514DcrRegistrationPersistenceResult = Literal["persisted", "reused", "skipped", "failed"] 

1515 

1516 

1517async def _persist_dcr_client_registration( 

1518 mcp_server: MCPServer, registration_response: object, current_redirect_uri: str 

1519) -> DcrRegistrationPersistenceResult: 

1520 """Persist the dynamically registered OAuth client (RFC 7591) to its single home: the server's 

1521 ``LiteLLM_MCPServerTable`` row when it has one, otherwise the server-scoped store when the server 

1522 is config-declared. A rowless server that is not config-declared is a throwaway temp/session 

1523 server, so its client is overlaid in memory only and not persisted. 

1524 

1525 The interactive authorization_code flow mints a ``client_id`` via Dynamic Client 

1526 Registration that discovery cannot re-derive; without persisting it the autonomous 

1527 ``refresh_token`` grant has no client identity, so an expired access token forces a 

1528 full re-authorization instead of a silent refresh. Mirrors the ``encrypt_credentials`` 

1529 write that ``client_credentials`` and token exchange already use. Failures are logged, 

1530 never raised: registration still returns to the caller even when persistence fails. 

1531 

1532 The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are skipped 

1533 unconditionally: the caller holds the upstream token and the gateway must hold no OAuth 

1534 client identity for these servers. Persisting here would stamp ``oauth2_flow`` and a 

1535 ``client_id`` onto a server whose mode promises the gateway stores nothing, making a 

1536 fresh pass-through server read as gateway-authorized. 

1537 

1538 ``redirect_uris`` records what the client is bound to so a later origin change can be 

1539 detected as a positive mismatch and trigger re-registration instead of stranding the 

1540 server on IdP-side redirect_uri rejections. ``client_secret`` and 

1541 ``token_endpoint_auth_method`` are written explicitly (None when absent) because 

1542 ``update_mcp_server`` merges credential blobs: a re-registered public client must not 

1543 inherit the previous client's secret or auth method. 

1544 """ 

1545 if mcp_server.is_client_forwarded_token: 

1546 return "skipped" 

1547 

1548 try: 

1549 registration: Final = _DcrClientRegistration.model_validate(registration_response) 

1550 except ValidationError as exc: 

1551 verbose_logger.warning( 

1552 "register_client_with_server: DCR response has no usable client_id for server_id=%s; " 

1553 "client registration not persisted (%s)", 

1554 mcp_server.server_id, 

1555 exc, 

1556 ) 

1557 return "failed" 

1558 

1559 if await _reuse_persisted_dcr_client_if_available(mcp_server, current_redirect_uri=current_redirect_uri): 

1560 return "reused" 

1561 

1562 token_endpoint_auth_method: Final = ( 

1563 "client_secret_basic" if registration.token_endpoint_auth_method == "client_secret_basic" else None 

1564 ) 

1565 credentials: Final[MCPCredentials] = { 

1566 "client_id": registration.client_id, 

1567 "client_secret": registration.client_secret, 

1568 "token_endpoint_auth_method": token_endpoint_auth_method, 

1569 "redirect_uris": [current_redirect_uri], 

1570 } 

1571 

1572 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # avoids circular import 

1573 McpIdentifierConflict, 

1574 update_mcp_server, 

1575 upsert_mcp_server_oauth_client_credentials, 

1576 ) 

1577 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 

1578 global_mcp_server_manager, 

1579 ) 

1580 from litellm.proxy._types import UpdateMCPServerRequest # noqa: PLC0415 

1581 from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 

1582 

1583 try: 

1584 prisma_client: Final = get_prisma_client_or_throw( 

1585 "Database not connected. Cannot persist MCP OAuth client registration." 

1586 ) 

1587 updated_row: Final = await update_mcp_server( 

1588 prisma_client=prisma_client, 

1589 data=( 

1590 UpdateMCPServerRequest( 

1591 server_id=mcp_server.server_id, 

1592 credentials=credentials, 

1593 oauth2_flow="authorization_code", 

1594 token_url=mcp_server.token_url, 

1595 ) 

1596 if mcp_server.token_url 

1597 else UpdateMCPServerRequest( 

1598 server_id=mcp_server.server_id, 

1599 credentials=credentials, 

1600 oauth2_flow="authorization_code", 

1601 ) 

1602 ), 

1603 touched_by="mcp_oauth_dcr", 

1604 ) 

1605 if updated_row is not None and not isinstance(updated_row, McpIdentifierConflict): 

1606 await global_mcp_server_manager.update_server(updated_row) 

1607 return "persisted" 

1608 if global_mcp_server_manager.is_config_declared_server(mcp_server.server_id): 

1609 await upsert_mcp_server_oauth_client_credentials( 

1610 prisma_client=prisma_client, 

1611 server_id=mcp_server.server_id, 

1612 credentials=credentials, 

1613 ) 

1614 mcp_server.client_id = registration.client_id 

1615 mcp_server.client_secret = registration.client_secret 

1616 mcp_server.token_endpoint_auth_method = token_endpoint_auth_method 

1617 return "persisted" 

1618 except Exception as exc: # noqa: BLE001 

1619 verbose_logger.warning( 

1620 "register_client_with_server: failed to persist DCR client registration for server_id=%s: %s", 

1621 mcp_server.server_id, 

1622 exc, 

1623 ) 

1624 return "failed" 

1625 

1626 

1627def client_supplied_redirect_uris(value: object) -> list[str] | None: 

1628 """RFC 7591 redirect_uris must be a non-empty array of URI strings. Any other shape (not a list, 

1629 an empty list, or a list holding a non-string or empty-string element) yields None so every 

1630 register arm falls back to the gateway callback instead of echoing a malformed value back to the 

1631 client as its redirect_uris. The redirect actually used is trust-validated later at /authorize by 

1632 validate_trusted_redirect_uri; this guard only keeps the client-facing echo well-typed.""" 

1633 if not isinstance(value, list) or not value: 1633 ↛ 1635line 1633 didn't jump to line 1635 because the condition on line 1633 was always true

1634 return None 

1635 uris: Final = [uri for uri in value if isinstance(uri, str) and uri] 

1636 return uris if len(uris) == len(value) else None 

1637 

1638 

1639async def _post_dcr_registration( 

1640 registration_url: str, 

1641 register_data: Mapping[str, object], 

1642 server_id: str, 

1643) -> httpx.Response: 

1644 """POST an RFC 7591 registration to the upstream and return its response, relaying a classified 

1645 upstream rejection instead of a generic 500 and failing loud on an absent response.""" 

1646 headers: Final = { 

1647 "Content-Type": "application/json", 

1648 "Accept": "application/json", 

1649 } 

1650 async_client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Register) 

1651 try: 

1652 response: Final = await async_client.post( 

1653 registration_url, 

1654 headers=headers, 

1655 json=register_data, 

1656 ) 

1657 response.raise_for_status() 

1658 except httpx.HTTPStatusError as exc: 

1659 status_code, detail = dcr_fault_detail(classify_upstream_dcr_rejection(exc.response, log_context=server_id)) 

1660 raise HTTPException(status_code=status_code, detail=detail) from exc 

1661 return response 

1662 

1663 

1664class EphemeralDcrClient(BaseModel): 

1665 """A DCR client minted for a single authorize round trip and never stored by the gateway.""" 

1666 

1667 model_config = ConfigDict(frozen=True) 

1668 client_id: str = Field(min_length=1) 

1669 client_secret: str | None = None 

1670 token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None 

1671 

1672 

1673_EPHEMERAL_DCR_CLIENT_CACHE: Final = InMemoryCache(default_ttl=_OAUTH_STATE_COOKIE_TTL_SECONDS) 

1674_EPHEMERAL_DCR_MINT_LOCKS: Final[dict[str, asyncio.Lock]] = {} 

1675 

1676 

1677async def mint_ephemeral_dcr_client(request: Request, mcp_server: MCPServer) -> EphemeralDcrClient | None: 

1678 """Mint a throwaway OAuth client via the upstream's RFC 7591 registration endpoint for a 

1679 client-forwarded-token server whose authorize arrived with no client_id. Returns ``None`` when 

1680 the upstream exposes no registration endpoint, so the caller keeps its existing failure path. 

1681 The minted client is deliberately not persisted anywhere: ``true_passthrough`` / 

1682 ``oauth_delegate`` require the gateway to hold no OAuth client identity, so it survives only in 

1683 the encrypted OAuth state and the sealed authorization code the callback forwards. 

1684 

1685 Reloading the authorize page or retrying a flow must not register a fresh upstream client every 

1686 time (an OAuth client identifies the application, not the user, so reuse is semantically 

1687 correct). A per-process TTL cache bounded to the OAuth state cookie's lifetime dedupes the mint 

1688 per (server, gateway origin), and a per-server lock single-flights concurrent mints (the 

1689 ``_OAUTH_METADATA_FETCH_LOCKS`` pattern; keyed by server_id alone so the lock registry stays 

1690 bounded by the server count even when the request origin varies) so parallel authorize requests 

1691 cannot each register an upstream client; the cache stamps nothing onto the server record and 

1692 correctness never depends on it because the sealed state carries the client through the flow.""" 

1693 registration_url: Final = mcp_server.effective_registration_url 

1694 if registration_url is None: 

1695 return None 

1696 request_base_url: Final = get_request_base_url(request) 

1697 cache_key: Final = f"mcp_ephemeral_dcr_client:{mcp_server.server_id}:{request_base_url}" 

1698 cached: Final = _EPHEMERAL_DCR_CLIENT_CACHE.get_cache(cache_key) 

1699 if isinstance(cached, EphemeralDcrClient): 

1700 return cached 

1701 lock: Final = _EPHEMERAL_DCR_MINT_LOCKS.setdefault(mcp_server.server_id, asyncio.Lock()) 

1702 async with lock: 

1703 cached_after_wait: Final = _EPHEMERAL_DCR_CLIENT_CACHE.get_cache(cache_key) 

1704 if isinstance(cached_after_wait, EphemeralDcrClient): 

1705 return cached_after_wait 

1706 register_data: Final[dict[str, object]] = { 

1707 "client_name": mcp_server.server_name or mcp_server.server_id, 

1708 "redirect_uris": [f"{request_base_url}/callback"], 

1709 "grant_types": ["authorization_code", "refresh_token"], 

1710 "response_types": ["code"], 

1711 "token_endpoint_auth_method": "none", 

1712 } 

1713 response: Final = await _post_dcr_registration( 

1714 registration_url=registration_url, 

1715 register_data=register_data, 

1716 server_id=mcp_server.server_id, 

1717 ) 

1718 try: 

1719 registration: Final = _DcrClientRegistration.model_validate_json(response.text) 

1720 except ValidationError as exc: 

1721 raise HTTPException( 

1722 status_code=502, 

1723 detail="MCP upstream registration endpoint returned no usable client_id", 

1724 ) from exc 

1725 if not registration.client_id: 

1726 raise HTTPException( 

1727 status_code=502, 

1728 detail="MCP upstream registration endpoint returned no usable client_id", 

1729 ) 

1730 minted: Final = EphemeralDcrClient( 

1731 client_id=registration.client_id, 

1732 client_secret=registration.client_secret, 

1733 token_endpoint_auth_method=normalize_token_endpoint_auth_method(registration.token_endpoint_auth_method), 

1734 ) 

1735 _EPHEMERAL_DCR_CLIENT_CACHE.set_cache(cache_key, minted) 

1736 return minted 

1737 

1738 

1739async def resolve_ephemeral_dcr_client( 

1740 request: Request, 

1741 mcp_server: MCPServer, 

1742 code_challenge: str | None, 

1743 code_challenge_method: str | None, 

1744 redirect_uri: str, 

1745) -> EphemeralDcrClient | None: 

1746 """The single owner of the gateway-side mint policy for a clientless authorize. Returns 

1747 ``None`` for servers whose mode does not permit gateway minting and for upstreams without a 

1748 registration endpoint, so those callers keep their existing failure paths: plain ``oauth2`` 

1749 keeps its persisted-client contract, and the interactive ``oauth_delegate`` dcr_bridge 

1750 sign-in has its own sealed-identity flow. ``true_passthrough`` mints regardless of the 

1751 ``dcr_bridge`` flag (the UI creates passthrough servers with the flag on by default): a 

1752 minted flow runs the bridge short-circuit arm, while the relay front door remains for 

1753 external clients that registered themselves. Flows that could never succeed fail loud 

1754 before any upstream registration: a missing ``authorization_url``, a downgraded PKCE pair 

1755 (without S256 the sealed code would be bearer-redeemable by any authenticated caller who 

1756 intercepts the redirect), or an untrusted ``redirect_uri`` (a rejected redirect must not be 

1757 usable to generate orphan IdP clients).""" 

1758 if not (mcp_server.is_true_passthrough or (mcp_server.is_oauth_delegate and not mcp_server.is_dcr_bridge)): 

1759 return None 

1760 if mcp_server.effective_authorization_url is None: 

1761 raise HTTPException( 

1762 status_code=400, 

1763 detail="MCP server authorization url is not set", 

1764 ) 

1765 _require_s256_pkce(code_challenge, code_challenge_method) 

1766 validate_trusted_redirect_uri(request, redirect_uri) 

1767 return await mint_ephemeral_dcr_client(request, mcp_server) 

1768 

1769 

1770def _register_flow_needed_endpoint(mcp_server: MCPServer) -> str | None: 

1771 """The register flow's deferred-discovery join gate. A DCR bridge with no admin-configured 

1772 client can only register callers through the upstream's registration endpoint 

1773 (``_oauth_endpoints_unresolved`` keeps its discovery slot armed for exactly this shape), so 

1774 the flow must keep joining discovery while registration is still missing instead of silently 

1775 degrading to the dummy short-circuit. Every other shape only needs the authorization url.""" 

1776 if mcp_server.is_dcr_bridge and not mcp_server.client_id and mcp_server.effective_registration_url is None: 

1777 return None 

1778 return mcp_server.effective_authorization_url 

1779 

1780 

1781def _token_flow_needed_endpoint(mcp_server: MCPServer) -> str | None: 

1782 """The token exchange's deferred-discovery join gate. The exchange's relay-vs-callback arm 

1783 (:func:`_dcr_bridge_relays_client_registration`) reads the registration url, so a clientless 

1784 DCR bridge rebuilt without its discovered registration endpoint must keep joining discovery 

1785 even when the token url already resolves; skipping it would select the gateway-callback arm 

1786 and the upstream would reject the code over a redirect_uri mismatch. Every other shape only 

1787 needs the token url.""" 

1788 if mcp_server.is_dcr_bridge and not mcp_server.client_id and mcp_server.effective_registration_url is None: 

1789 return None 

1790 return mcp_server.effective_token_url 

1791 

1792 

1793async def register_client_with_server( 

1794 request: Request, 

1795 mcp_server: MCPServer, 

1796 client_name: str, 

1797 grant_types: list | None, 

1798 response_types: list | None, 

1799 token_endpoint_auth_method: str | None, 

1800 fallback_client_id: str | None = None, 

1801 persist_credentials: bool = False, 

1802 client_redirect_uris: list[str] | None = None, 

1803): 

1804 _raise_if_not_oauth2(mcp_server) 

1805 request_base_url: Final = get_request_base_url(request) 

1806 current_redirect_uri: Final = f"{request_base_url}/callback" 

1807 client_facing_redirect_uris: Final = client_redirect_uris or [current_redirect_uri] 

1808 dummy_return: Final = { 

1809 "client_id": fallback_client_id or mcp_server.server_name, 

1810 "client_secret": "dummy", 

1811 "redirect_uris": client_facing_redirect_uris, 

1812 } 

1813 

1814 if mcp_server.client_id and not ( 

1815 persist_credentials 

1816 and mcp_server.registration_url 

1817 and await _persisted_dcr_redirect_uri_is_stale(mcp_server, current_redirect_uri) 

1818 ): 

1819 return dummy_return 

1820 

1821 if await _reuse_persisted_dcr_client_if_available( 

1822 mcp_server, 

1823 current_redirect_uri=current_redirect_uri if persist_credentials else None, 

1824 ): 

1825 return dummy_return 

1826 

1827 resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _register_flow_needed_endpoint) 

1828 if resolved_server.effective_authorization_url is None: 

1829 raise HTTPException( 

1830 status_code=400, 

1831 detail=_endpoint_not_configured_detail( 

1832 resolved_server, 

1833 "authorization url", 

1834 "set Authorization URL and Token URL manually", 

1835 "set Issuer to discover them from the identity provider (RFC 8414)", 

1836 ), 

1837 ) 

1838 

1839 registration_url: Final = resolved_server.effective_registration_url 

1840 if registration_url is None: 

1841 return dummy_return 

1842 

1843 bridge_relay: Final = _dcr_bridge_relays_client_registration(resolved_server) 

1844 if bridge_relay and not client_redirect_uris: 

1845 raise HTTPException( 

1846 status_code=400, 

1847 detail="redirect_uris is required to register a client with this server", 

1848 ) 

1849 

1850 register_data: Final = { 

1851 "client_name": client_name, 

1852 "redirect_uris": client_redirect_uris if bridge_relay else [current_redirect_uri], 

1853 "grant_types": grant_types or (["authorization_code", "refresh_token"] if bridge_relay else []), 

1854 "response_types": response_types or (["code"] if bridge_relay else []), 

1855 "token_endpoint_auth_method": token_endpoint_auth_method or ("none" if bridge_relay else ""), 

1856 } 

1857 response: Final = await _post_dcr_registration( 

1858 registration_url=registration_url, 

1859 register_data=register_data, 

1860 server_id=resolved_server.server_id, 

1861 ) 

1862 

1863 token_response = response.json() 

1864 

1865 if persist_credentials and not bridge_relay: 

1866 persistence_result = await _persist_dcr_client_registration( 

1867 resolved_server, token_response, current_redirect_uri 

1868 ) 

1869 if persistence_result == "reused": 

1870 return dummy_return 

1871 

1872 if client_redirect_uris and not bridge_relay and isinstance(token_response, dict): 

1873 token_response = {**token_response, "redirect_uris": client_facing_redirect_uris} 

1874 

1875 return JSONResponse(token_response) 

1876 

1877 

1878@router.get("/authorize/mcp-session") 

1879async def authorize_mcp_session( 

1880 request: Request, 

1881 redirect_uri: str, 

1882 client_id: str, 

1883 state: str = "", 

1884 code_challenge: str | None = None, 

1885 code_challenge_method: str | None = None, 

1886 response_type: str | None = None, 

1887 resource: str | None = None, 

1888) -> Response: 

1889 return aggregate_authorize( 

1890 request=request, 

1891 client_id=client_id, 

1892 redirect_uri=redirect_uri, 

1893 state=state, 

1894 code_challenge=code_challenge, 

1895 code_challenge_method=code_challenge_method, 

1896 response_type=response_type, 

1897 session_user_id=_session_cookie_user_id(request), 

1898 resource=resource, 

1899 ) 

1900 

1901 

1902@router.get("/{mcp_server_name}/authorize") 

1903@router.get("/authorize") 

1904async def authorize( 

1905 request: Request, 

1906 redirect_uri: str, 

1907 client_id: str | None = None, 

1908 state: str = "", 

1909 mcp_server_name: str | None = None, 

1910 code_challenge: str | None = None, 

1911 code_challenge_method: str | None = None, 

1912 response_type: str | None = None, 

1913 scope: str | None = None, 

1914 resource: str | None = None, 

1915): 

1916 # Redirect to real OAuth provider with PKCE support 

1917 if mcp_server_name is None and client_id and is_gateway_dcr_client_id(client_id): 1917 ↛ 1918line 1917 didn't jump to line 1918 because the condition on line 1917 was never true

1918 if is_proxy_api_resource(request, resource): 

1919 return await native_client_authorize( 

1920 request=request, 

1921 client_id=client_id, 

1922 redirect_uri=redirect_uri, 

1923 state=state, 

1924 code_challenge=code_challenge, 

1925 code_challenge_method=code_challenge_method, 

1926 response_type=response_type, 

1927 session_user_id=_session_cookie_user_id(request), 

1928 lookup_consent_teams=lookup_consent_teams, 

1929 ) 

1930 return aggregate_authorize( 

1931 request=request, 

1932 client_id=client_id, 

1933 redirect_uri=redirect_uri, 

1934 state=state, 

1935 code_challenge=code_challenge, 

1936 code_challenge_method=code_challenge_method, 

1937 response_type=response_type, 

1938 session_user_id=_session_cookie_user_id(request), 

1939 resource=resource, 

1940 ) 

1941 

1942 lookup_name: Final[str | None] = mcp_server_name or client_id 

1943 client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) 

1944 mcp_server = _resolve_mcp_server_by_name_or_id(lookup_name, client_ip) if lookup_name else None 

1945 if mcp_server is None and mcp_server_name is None: 

1946 mcp_server = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) 

1947 if mcp_server is None: 1947 ↛ 1949line 1947 didn't jump to line 1949 because the condition on line 1947 was always true

1948 raise HTTPException(status_code=404, detail="MCP server not found") 

1949 _raise_if_not_oauth2(mcp_server) 

1950 # Use server's stored client_id when caller doesn't supply one. 

1951 # Raise a clear error instead of passing an empty string — an empty 

1952 # client_id would silently produce a broken authorization URL. 

1953 resolved_client_id: Final[str] = mcp_server.client_id or client_id or "" 

1954 if not resolved_client_id: 

1955 raise HTTPException( 

1956 status_code=400, 

1957 detail={ 

1958 "error": "client_id is required but was not supplied and is not " 

1959 "stored on the MCP server record. Provide client_id as a query " 

1960 "parameter or configure it on the server." 

1961 }, 

1962 ) 

1963 return await authorize_with_server( 

1964 request=request, 

1965 mcp_server=mcp_server, 

1966 client_id=resolved_client_id, 

1967 redirect_uri=redirect_uri, 

1968 state=state, 

1969 code_challenge=code_challenge, 

1970 code_challenge_method=code_challenge_method, 

1971 response_type=response_type, 

1972 scope=scope, 

1973 ) 

1974 

1975 

1976@router.post("/{mcp_server_name}/token") 

1977@router.post("/token") 

1978async def token_endpoint( 

1979 request: Request, 

1980 grant_type: str = Form(...), 

1981 code: str = Form(None), 

1982 redirect_uri: str = Form(None), 

1983 client_id: str = Form(...), 

1984 client_secret: str | None = Form(None), 

1985 code_verifier: str = Form(None), 

1986 refresh_token: str | None = Form(None), 

1987 scope: str | None = Form(None), 

1988 resource: str | None = Form(None), 

1989 subject_token: str | None = Form(None), 

1990 subject_token_type: str | None = Form(None), 

1991 requested_token_type: str | None = Form(None), 

1992 mcp_server_name: str | None = None, 

1993): 

1994 """ 

1995 Accept the authorization code from client and exchange it for OAuth token. 

1996 Supports PKCE flow by forwarding code_verifier to upstream provider. 

1997 

1998 1. Call the token endpoint with PKCE parameters 

1999 2. Store the user's token in the db - and generate a LiteLLM virtual key 

2000 3. Return the token 

2001 4. Return a virtual key in this response 

2002 """ 

2003 if mcp_server_name is None and is_gateway_dcr_client_id(client_id): 2003 ↛ 2004line 2003 didn't jump to line 2004 because the condition on line 2003 was never true

2004 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load 

2005 master_key, 

2006 user_api_key_cache, 

2007 ) 

2008 

2009 return await aggregate_token( 

2010 request=request, 

2011 grant_type=grant_type, 

2012 code=code, 

2013 redirect_uri=redirect_uri, 

2014 client_id=client_id, 

2015 code_verifier=code_verifier, 

2016 refresh_token=refresh_token, 

2017 master_key=master_key, 

2018 reload_user=_reload_active_user_by_id, 

2019 cache=user_api_key_cache, 

2020 resource=resource, 

2021 mint_proxy_credential=mint_proxy_credential, 

2022 subject_token=subject_token, 

2023 subject_token_type=subject_token_type, 

2024 requested_token_type=requested_token_type, 

2025 exchange_subject_token=exchange_idp_subject_token, 

2026 ) 

2027 

2028 lookup_name: Final = mcp_server_name or client_id 

2029 client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) 

2030 mcp_server = _resolve_mcp_server_by_name_or_id(lookup_name, client_ip) 

2031 if mcp_server is None and mcp_server_name is None: 

2032 mcp_server = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) 

2033 if mcp_server is None: 2033 ↛ 2035line 2033 didn't jump to line 2035 because the condition on line 2033 was always true

2034 raise HTTPException(status_code=404, detail="MCP server not found") 

2035 return await exchange_token_with_server( 

2036 request=request, 

2037 mcp_server=mcp_server, 

2038 grant_type=grant_type, 

2039 code=code, 

2040 redirect_uri=redirect_uri, 

2041 client_id=client_id, 

2042 client_secret=client_secret, 

2043 code_verifier=code_verifier, 

2044 refresh_token=refresh_token, 

2045 scope=scope, 

2046 ) 

2047 

2048 

2049async def _vendor_credential_state(user_id: str, server_id: str) -> VendorCredentialState: 

2050 """Whether the gateway itself can see a live vendor credential for this user and server. 

2051 

2052 The one reading of "authorized" the connect page displays and the finish step enforces, so 

2053 the button a user sees and the grant they get cannot disagree. A read fault is neither, and 

2054 fails the scoped grant closed.""" 

2055 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # circular import at module load 

2056 get_user_oauth_credential, 

2057 oauth_grant_state, 

2058 ) 

2059 from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # circular import at module load 

2060 

2061 if prisma_client is None: 

2062 return "unavailable" 

2063 try: 

2064 credential: Final = await get_user_oauth_credential(prisma_client, user_id, server_id) 

2065 except Exception: # noqa: BLE001 # a credential-read fault must fail the scoped grant closed 

2066 return "unavailable" 

2067 return "absent" if oauth_grant_state(credential) == "absent" else "present" 

2068 

2069 

2070@router.get("/authorize/flow") 

2071async def authorize_flow(request: Request, flow: str) -> Response: 

2072 return await describe_connect_flow( 

2073 request=request, 

2074 flow_handle=flow, 

2075 session_user_id=_session_cookie_user_id(request), 

2076 lookup_vendor_credential=_vendor_credential_state, 

2077 lookup_server_reachability=_user_can_reach_mcp_server, 

2078 ) 

2079 

2080 

2081@router.post("/authorize/complete") 

2082async def authorize_complete( 

2083 request: Request, 

2084 flow: str = Form(...), 

2085 delivery: str | None = Form(None), 

2086 team_id: str | None = Form(None), 

2087 decision: str | None = Form(None), 

2088) -> Response: 

2089 """Finish an aggregate connect flow: mint the gateway authorization code for the 

2090 signed-in user and hand it back to the DCR client, by 303 redirect (default) or, for 

2091 a loopback client on a different machine, as a copyable callback URL 

2092 (``delivery=manual``). POST plus the per-flow HttpOnly cookie set at /authorize; an 

2093 anonymous or bad-flow request just 400s. The native-client consent page adds 

2094 ``decision`` (approve or deny) and the ``team_id`` the credential is attributed to.""" 

2095 from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 # circular import at module load 

2096 

2097 return await complete_connect_flow( 

2098 request=request, 

2099 flow_handle=flow, 

2100 session_user_id=_session_cookie_user_id(request), 

2101 cache=user_api_key_cache, 

2102 delivery=delivery, 

2103 team_id=team_id, 

2104 decision=decision, 

2105 lookup_vendor_credential=_vendor_credential_state, 

2106 lookup_server_reachability=_user_can_reach_mcp_server, 

2107 ) 

2108 

2109 

2110@router.post("/revoke") 

2111async def revoke_endpoint(request: Request, token: str = Form(...), client_id: str = Form(...)) -> Response: 

2112 """RFC 7009 revocation for the gateway's refresh tokens (``lite logout``): 200 for a known 

2113 client whatever the token's state, 503 when the shared single-use record cannot be written; 

2114 access tokens expire on their own.""" 

2115 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load 

2116 master_key, 

2117 user_api_key_cache, 

2118 ) 

2119 

2120 return await revoke_refresh_token(token=token, client_id=client_id, master_key=master_key, cache=user_api_key_cache) 

2121 

2122 

2123@router.post("/introspect", dependencies=[Depends(user_api_key_auth)]) 

2124async def introspect_endpoint(token: str = Form(...)) -> Response: 

2125 """RFC 7662 introspection for gateway-issued session tokens (``llm_session_`` / 

2126 ``llm_srefresh_``), so an external gateway can validate them without the signing 

2127 secret. The caller authenticates with a LiteLLM virtual key (section 2.1, enforced by 

2128 the route dependency); any token the gateway cannot vouch for answers 

2129 ``{"active": false}`` with no further detail.""" 

2130 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load 

2131 master_key, 

2132 user_api_key_cache, 

2133 ) 

2134 

2135 return await introspect_gateway_token( 

2136 token=token, 

2137 master_key=master_key, 

2138 reload_user=_reload_active_user_by_id, 

2139 cache=user_api_key_cache, 

2140 ) 

2141 

2142 

2143@router.get("/.well-known/litellm-cli-auth") 

2144async def native_client_auth_discovery(request: Request) -> JSONResponse: 

2145 """The versioned contract a native client (``lite login --pkce``, or a CLI in any other 

2146 language) reads to sign a user in through the browser and obtain a proxy credential.""" 

2147 return JSONResponse( 

2148 native_client_auth_contract(request, token_exchange_available()), headers=TOKEN_NO_CACHE_HEADERS 

2149 ) 

2150 

2151 

2152# Per RFC 6749 §4.1.2.1, an IdP that rejects an OAuth authorization request 

2153# redirects back to the configured redirect URI with ``error`` / 

2154# ``error_description`` / ``error_uri`` query params and no ``code``. The MCP 

2155# loopback flow funnels that response through this /callback endpoint, so 

2156# the endpoint must accept either a successful (``code``+``state``) or an 

2157# error response. Declaring ``code``/``state`` as required would cause 

2158# FastAPI to reject the error response with a 422 before the handler runs, 

2159# which strands the MCP client waiting on the loopback (see LIT-2750). 

2160 

2161 

2162def _render_oauth_error_html(error: str, description: str | None) -> HTMLResponse: 

2163 """Render an actionable HTML page for an IdP-reported OAuth error. 

2164 

2165 Used when we cannot propagate the error back to the registered 

2166 ``redirect_uri`` (state missing or undecryptable). Returned with a 400 

2167 status so the failure is observable to operators while still being a 

2168 human-readable page for the end user. 

2169 """ 

2170 safe_error: Final = _html.escape(error or "unknown_error") 

2171 safe_description: Final = _html.escape(description) if description else "" 

2172 description_html: Final = f"<p>{safe_description}</p>" if safe_description else "" 

2173 body: Final = ( 

2174 "<html><body>" 

2175 "<h2>Authentication failed</h2>" 

2176 f"<p><strong>Error:</strong> {safe_error}</p>" 

2177 f"{description_html}" 

2178 "<p>You can close this window and try again.</p>" 

2179 "</body></html>" 

2180 ) 

2181 return HTMLResponse(body, status_code=400) 

2182 

2183 

2184@router.get("/callback") 

2185async def callback( 

2186 request: Request, 

2187 code: str | None = None, 

2188 state: str | None = None, 

2189 error: str | None = None, 

2190 error_description: str | None = None, 

2191 error_uri: str | None = None, 

2192): 

2193 """OAuth 2.0 authorization response handler for MCP loopback clients. 

2194 

2195 Accepts either: 

2196 

2197 - A successful authorization response (``code`` + ``state``), which is 

2198 forwarded back to the validated client ``redirect_uri`` with the 

2199 original (un-wrapped) ``state``. 

2200 - An error response (``error``[+``error_description``/``error_uri``]), per 

2201 RFC 6749 §4.1.2.1. When ``state`` is present and decodes to a trusted 

2202 ``redirect_uri``, the error params are propagated back to the client so 

2203 its OAuth library can surface them. Otherwise we render an HTML error 

2204 page so the user is not left on an opaque 422 / blank screen. 

2205 """ 

2206 # 1. IdP-reported error path (e.g. ``?error=access_denied``). 

2207 if error: 

2208 verbose_logger.info( 

2209 "MCP /callback received IdP error: error=%s, error_description=%s", 

2210 error, 

2211 error_description, 

2212 ) 

2213 if state: 

2214 encoded_state = _resolve_encoded_oauth_state(request, state) 

2215 try: 

2216 state_data = decode_state_hash(encoded_state) 

2217 original_state = state_data.get("original_state") 

2218 redirect_uri = _get_validated_client_redirect_uri(request, state_data) 

2219 except Exception: 

2220 # Untrusted/invalid client redirect_uri (HTTPException), or an 

2221 # undecryptable state (expired key, tampered): surface the IdP 

2222 # error inline rather than forwarding it to an attacker-controlled 

2223 # URL, and drop the one-time cookie we can no longer consume. 

2224 response = _render_oauth_error_html(error, error_description) 

2225 _clear_oauth_state_cookie(response, request, state) 

2226 return response 

2227 

2228 params: dict[str, str] = {"error": error} 

2229 if error_description: 

2230 params["error_description"] = error_description 

2231 if error_uri: 

2232 params["error_uri"] = error_uri 

2233 if original_state is not None: 

2234 params["state"] = original_state 

2235 complete_returned_url = _append_query_params(redirect_uri, params) 

2236 response = RedirectResponse(url=complete_returned_url, status_code=302) 

2237 _clear_oauth_state_cookie(response, request, state) 

2238 return response 

2239 

2240 # No state — nothing to round-trip to. Show the user the error. 

2241 return _render_oauth_error_html(error, error_description) 

2242 

2243 # 2. Neither success nor error parameters present — most likely a stray 

2244 # GET / dropped SSO redirect chain. Surface a 400 instead of 422. 

2245 if not code or not state: 

2246 missing: Final = [name for name, value in (("code", code), ("state", state)) if not value] 

2247 return _render_oauth_error_html( 

2248 "invalid_request", 

2249 f"Missing authorization {' and '.join(repr(m) for m in missing)} parameter(s).", 

2250 ) 

2251 

2252 # 3. Successful authorization response. 

2253 try: 

2254 encoded_state = _resolve_encoded_oauth_state(request, state) 

2255 state_data = decode_state_hash(encoded_state) 

2256 original_state = state_data["original_state"] 

2257 

2258 # Re-validate the client redirect URI at the sink. /authorize 

2259 # rejects untrusted URIs before encoding them into state, but 

2260 # encrypted states minted before that check was added have no 

2261 # expiry and remain valid indefinitely. Validating here blocks 

2262 # the open-redirect + code-theft primitive even for pre-fix 

2263 # states while permitting same-origin / allowlisted clients. 

2264 redirect_uri = _get_validated_client_redirect_uri(request, state_data) 

2265 

2266 # Interactive dcr_bridge oauth_delegate: the state carries the litellm user the authorize step 

2267 # captured. Instead of forwarding the raw upstream code (which the client would present at the 

2268 # token endpoint with no way to prove who signed in), seal the user and the upstream code into a 

2269 # gateway authorization code and forward THAT. The token endpoint decrypts it to bind the 

2270 # envelope to this user. Every other flow forwards the raw code unchanged. 

2271 litellm_user_id: Final = state_data.get("litellm_user_id") 

2272 mcp_server_id: Final = state_data.get("mcp_server_id") 

2273 dcr_client_id: Final = state_data.get("dcr_client_id") 

2274 dcr_client_secret: Final = state_data.get("dcr_client_secret") 

2275 forwarded_code = code 

2276 if isinstance(litellm_user_id, str) and litellm_user_id and isinstance(mcp_server_id, str) and mcp_server_id: 

2277 forwarded_code = seal_bridge_authorization_code( 

2278 upstream_code=code, 

2279 litellm_user_id=litellm_user_id, 

2280 mcp_server_id=mcp_server_id, 

2281 oauth_nonce=state_data.get("oauth_nonce"), 

2282 ) 

2283 elif isinstance(dcr_client_id, str) and dcr_client_id and isinstance(mcp_server_id, str) and mcp_server_id: 

2284 forwarded_code = seal_passthrough_authorization_code( 

2285 upstream_code=code, 

2286 client_id=dcr_client_id, 

2287 client_secret=dcr_client_secret if isinstance(dcr_client_secret, str) and dcr_client_secret else None, 

2288 mcp_server_id=mcp_server_id, 

2289 token_endpoint_auth_method=normalize_token_endpoint_auth_method( 

2290 state_data.get("dcr_token_endpoint_auth_method") 

2291 ), 

2292 ) 

2293 

2294 params = {"code": forwarded_code, "state": original_state} 

2295 complete_returned_url = _append_query_params(redirect_uri, params) 

2296 response = RedirectResponse(url=complete_returned_url, status_code=302) 

2297 _clear_oauth_state_cookie(response, request, state) 

2298 return response 

2299 

2300 except HTTPException: 

2301 # Re-raise so a non-loopback base_url surfaces as 400 instead of 

2302 # a generic "authentication incomplete" redirect. 

2303 raise 

2304 except Exception: 

2305 response = HTMLResponse("<html><body>Authentication incomplete. You can close this window.</body></html>") 

2306 _clear_oauth_state_cookie(response, request, state) 

2307 return response 

2308 

2309 

2310# ------------------------------ 

2311# Optional .well-known endpoints for MCP + OAuth discovery 

2312# ------------------------------ 

2313""" 

2314 Per SEP-985, the client MUST: 

2315 1. Try resource_metadata from WWW-Authenticate header (if present) 

2316 2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path} 

2317 ( 

2318 If the resource identifier value contains a path or query component, any terminating slash (/) 

2319 following the host component MUST be removed before inserting /.well-known/ and the well-known 

2320 URI path suffix between the host component and the path(include root path) and/or query components. 

2321 https://datatracker.ietf.org/doc/html/rfc9728#section-3.1) 

2322 3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource 

2323 

2324 Dual Pattern Support: 

2325 - Standard MCP pattern: /mcp/{server_name} (recommended, used by mcp-inspector, VSCode Copilot) 

2326 - LiteLLM legacy pattern: /{server_name}/mcp (backward compatibility) 

2327 

2328 The resource URL returned matches the pattern used in the discovery request. 

2329""" 

2330 

2331 

2332async def fetch_upstream_oauth_protected_resource( 

2333 mcp_server: MCPServer, 

2334) -> dict | None: 

2335 """Fetch the upstream MCP server's ``.well-known/oauth-protected-resource`` 

2336 metadata for a pass-through server. 

2337 

2338 Tries host-only first, then falls back to the RFC 9728 §3.1 path-suffix 

2339 form (e.g. ``https://host/.well-known/oauth-protected-resource/mcp``) to 

2340 cover upstreams that scope metadata per resource path. 

2341 

2342 Responses are cached in-process for ~5 minutes keyed on 

2343 ``(server_id, resource_url)`` so we do not hammer the IdP. 

2344 

2345 Returns the parsed JSON dict on success, or ``None`` if neither form 

2346 responds with a 2xx JSON payload. Raises on network/connection errors so 

2347 the caller can emit HTTP 502 rather than fabricate a gateway response. 

2348 """ 

2349 if not mcp_server.url: 

2350 return None 

2351 

2352 upstream: Final = urlparse(mcp_server.url) 

2353 if not upstream.scheme or not upstream.netloc: 

2354 return None 

2355 

2356 cache_key: Final = (mcp_server.server_id, mcp_server.url) 

2357 now = time.time() 

2358 _prune_oauth_metadata_cache(now) 

2359 cached = _OAUTH_METADATA_CACHE.get(cache_key) 

2360 if cached is not None and cached[0] > now: 

2361 return cached[1] 

2362 

2363 lock: Final = _OAUTH_METADATA_FETCH_LOCKS.setdefault(cache_key, asyncio.Lock()) 

2364 async with lock: 

2365 now = time.time() 

2366 cached = _OAUTH_METADATA_CACHE.get(cache_key) 

2367 if cached is not None and cached[0] > now: 

2368 return cached[1] 

2369 

2370 host_base: Final = f"{upstream.scheme}://{upstream.netloc}" 

2371 candidates: Final = [f"{host_base}/.well-known/oauth-protected-resource"] 

2372 # RFC 9728 §3.1 path fallback 

2373 if upstream.path and upstream.path not in ("", "/"): 

2374 candidates.append(f"{host_base}/.well-known/oauth-protected-resource{upstream.path.rstrip('/')}") 

2375 

2376 async_client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) 

2377 

2378 network_errors: Final[list[Exception]] = [] 

2379 for candidate in candidates: 

2380 try: 

2381 response = await async_client.get( 

2382 candidate, 

2383 headers={"Accept": "application/json"}, 

2384 ) 

2385 except Exception as exc: 

2386 if is_network_error(exc): 

2387 network_errors.append(exc) 

2388 else: 

2389 verbose_logger.warning( 

2390 "MCP OAuth metadata fetch for %s raised non-transport " 

2391 "%s: %s — treating as no metadata for this candidate", 

2392 candidate, 

2393 type(exc).__name__, 

2394 exc, 

2395 ) 

2396 continue 

2397 if response.status_code == 200: 

2398 try: 

2399 payload = response.json() 

2400 except Exception as exc: 

2401 verbose_logger.warning( 

2402 "MCP OAuth metadata at %s returned 200 but JSON " 

2403 "decode failed (%s: %s) — treating as no metadata", 

2404 candidate, 

2405 type(exc).__name__, 

2406 exc, 

2407 ) 

2408 continue 

2409 if isinstance(payload, dict): 

2410 now = time.time() 

2411 _OAUTH_METADATA_CACHE[cache_key] = ( 

2412 now + _OAUTH_METADATA_CACHE_TTL_SECONDS, 

2413 payload, 

2414 ) 

2415 _prune_oauth_metadata_cache(now) 

2416 return payload 

2417 

2418 if len(network_errors) == len(candidates): 

2419 raise network_errors[-1] 

2420 

2421 # Negative-result caching: when no candidate yielded a usable payload, 

2422 # remember that for a shorter TTL so we don't re-fetch on every 

2423 # subsequent discovery request (and so the per-key lock can be pruned). 

2424 now = time.time() 

2425 _OAUTH_METADATA_CACHE[cache_key] = ( 

2426 now + _OAUTH_METADATA_NEGATIVE_CACHE_TTL_SECONDS, 

2427 None, 

2428 ) 

2429 _prune_oauth_metadata_cache(now) 

2430 return None 

2431 

2432 

2433def is_network_error(exc: Exception) -> bool: 

2434 """True for transport-layer failures (connection refused, DNS, TLS, timeout) 

2435 as opposed to HTTP protocol errors (4xx/5xx with a valid response).""" 

2436 return isinstance(exc, httpx.TransportError) 

2437 

2438 

2439async def _build_oauth_protected_resource_response( 

2440 request: Request, 

2441 mcp_server_name: str | None, 

2442 use_standard_pattern: bool, 

2443) -> dict: 

2444 """ 

2445 Build OAuth protected resource response with the appropriate URL pattern. 

2446 

2447 For pass-through MCP servers, the gateway proxies the upstream's own 

2448 ``oauth-protected-resource`` metadata so standards-compliant MCP clients 

2449 discover the **upstream** IdP instead of the gateway. For ``true_passthrough`` 

2450 and ``oauth_delegate`` the metadata is returned verbatim (``resource`` stays 

2451 the upstream): the caller's token is forwarded to and validated by the 

2452 upstream, so its audience must be the upstream — rewriting it to the gateway 

2453 would make a strict IdP (e.g. Entra) refuse to mint it or the upstream reject 

2454 it. Only the legacy ``is_oauth_passthrough`` opt-in rewrites ``resource`` to 

2455 the gateway's own URL so clients present the bearer token back to the gateway. 

2456 

2457 An explicitly named server with gateway-owned sign-in advertises the gateway's own 

2458 authorization server (``{base}/mcp``): a keyless DCR client that configured the 

2459 per-server URL completes the same sign-in flow the aggregate ``/mcp`` endpoint 

2460 supports and is admitted with a gateway session bearer. The per-server relay 

2461 authorize/token endpoints stay registered for the keyed interactive flow (which 

2462 is challenged with an explicit ``authorization_uri``). 

2463 

2464 Args: 

2465 request: FastAPI Request object 

2466 mcp_server_name: Name of the MCP server 

2467 use_standard_pattern: If True, use /mcp/{server_name} pattern; 

2468 if False, use /{server_name}/mcp pattern 

2469 

2470 Returns: 

2471 OAuth protected resource metadata dict 

2472 """ 

2473 if mcp_server_name is None: 2473 ↛ 2474line 2473 didn't jump to line 2474 because the condition on line 2473 was never true

2474 return oauth_protected_resource_root(request) 

2475 

2476 request_base_url: Final = get_request_base_url(request) 

2477 client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) 

2478 

2479 mcp_server: MCPServer | None = None 

2480 if mcp_server_name: 2480 ↛ 2484line 2480 didn't jump to line 2484 because the condition on line 2480 was always true

2481 mcp_server = _resolve_mcp_server_by_name_or_id(mcp_server_name, client_ip) 

2482 

2483 # Build resource URL based on the pattern 

2484 if mcp_server_name: 2484 ↛ 2492line 2484 didn't jump to line 2492 because the condition on line 2484 was always true

2485 if use_standard_pattern: 

2486 # Standard MCP pattern: /mcp/{server_name} 

2487 resource_url = f"{request_base_url}/mcp/{mcp_server_name}" 

2488 else: 

2489 # LiteLLM legacy pattern: /{server_name}/mcp 

2490 resource_url = f"{request_base_url}/{mcp_server_name}/mcp" 

2491 else: 

2492 resource_url = f"{request_base_url}/mcp" 

2493 

2494 if mcp_server is not None and mcp_server_name and mcp_server.is_dcr_bridge: 2494 ↛ 2495line 2494 didn't jump to line 2495 because the condition on line 2494 was never true

2495 return { 

2496 "authorization_servers": [f"{request_base_url}/{mcp_server_name}"], 

2497 "resource": resource_url, 

2498 "scopes_supported": (mcp_server.scopes if mcp_server.scopes else []), 

2499 } 

2500 

2501 # Pass-through branch: proxy the upstream's own metadata so discovery 

2502 # directs the client at the real IdP (Okta, Keycloak, …) instead of us. 

2503 if mcp_server is not None and ( 2503 ↛ 2506line 2503 didn't jump to line 2506 because the condition on line 2503 was never true

2504 mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate or mcp_server.is_true_passthrough 

2505 ): 

2506 try: 

2507 upstream_metadata: Final = await fetch_upstream_oauth_protected_resource(mcp_server) 

2508 except Exception as exc: 

2509 verbose_logger.warning( 

2510 "Failed to fetch upstream oauth-protected-resource metadata for pass-through MCP server %r: %s", 

2511 mcp_server.name, 

2512 exc, 

2513 ) 

2514 raise HTTPException( 

2515 status_code=502, 

2516 detail=( 

2517 f"Failed to fetch upstream oauth-protected-resource metadata for MCP server {mcp_server.name!r}" 

2518 ), 

2519 ) 

2520 

2521 if upstream_metadata is not None: 

2522 if mcp_server.is_client_forwarded_token: 

2523 return upstream_metadata 

2524 return {**upstream_metadata, "resource": resource_url} 

2525 

2526 # Upstream responded but with non-200 or non-dict payload. For 

2527 # pass-through servers the gateway is NOT the authorization server, 

2528 # so we must not fall through to the default gateway metadata — 

2529 # that would point clients at the wrong IdP. 

2530 verbose_logger.warning( 

2531 "Upstream oauth-protected-resource metadata unavailable for pass-through MCP server %r", mcp_server.name 

2532 ) 

2533 raise HTTPException( 

2534 status_code=502, 

2535 detail=(f"Upstream oauth-protected-resource metadata unavailable for MCP server {mcp_server.name!r}"), 

2536 ) 

2537 

2538 obo_response: Final = _obo_protected_resource_response(mcp_server, resource_url) 

2539 if obo_response is not None: 2539 ↛ 2540line 2539 didn't jump to line 2540 because the condition on line 2539 was never true

2540 return obo_response 

2541 

2542 if mcp_server is not None and mcp_server.advertises_gateway_authorization_server: 2542 ↛ 2543line 2542 didn't jump to line 2543 because the condition on line 2542 was never true

2543 return { 

2544 "authorization_servers": [f"{request_base_url}/mcp"], 

2545 "resource": resource_url, 

2546 "scopes_supported": (mcp_server.scopes if mcp_server.scopes else []), 

2547 } 

2548 

2549 if mcp_server is None or mcp_server.auth_type != MCPAuth.oauth2_token_exchange: 2549 ↛ 2552line 2549 didn't jump to line 2552 because the condition on line 2549 was always true

2550 _raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth-protected resource") 

2551 

2552 return { 

2553 "authorization_servers": [ 

2554 (f"{request_base_url}/{mcp_server_name}" if mcp_server_name else f"{request_base_url}") 

2555 ], 

2556 "resource": resource_url, 

2557 "scopes_supported": (mcp_server.scopes if mcp_server and mcp_server.scopes else []), 

2558 } 

2559 

2560 

2561def _obo_protected_resource_response(mcp_server: MCPServer | None, resource_url: str) -> dict | None: 

2562 """The OBO (token_exchange) PRM, or None when this server is not OBO / no issuer is configured. 

2563 

2564 The client SSOs with the IdP to obtain a subject token, which LiteLLM then exchanges, so discovery 

2565 points at the JWT-auth issuer(s) LiteLLM trusts (the same IdP that issues and validates the 

2566 subject), not the gateway. None falls the caller back to the gateway default so discovery still 

2567 returns metadata; it just can't name the IdP. 

2568 """ 

2569 if mcp_server is None or mcp_server.auth_type != MCPAuth.oauth2_token_exchange: 2569 ↛ 2571line 2569 didn't jump to line 2571 because the condition on line 2569 was always true

2570 return None 

2571 issuers: Final = _jwt_auth_issuers() 

2572 if not issuers: 

2573 return None 

2574 return { 

2575 "authorization_servers": issuers, 

2576 "resource": resource_url, 

2577 "scopes_supported": (mcp_server.scopes if mcp_server.scopes else []), 

2578 } 

2579 

2580 

2581def _jwt_auth_issuers() -> list: 

2582 """The OAuth issuer identifier(s) LiteLLM's JWT auth trusts, for the OBO PRM authorization_servers. 

2583 

2584 In token_exchange the IdP that issues the subject JWT is the same one LiteLLM validates it 

2585 against, so OBO discovery points clients at the JWT-auth issuer to obtain a subject token. 

2586 Sourced from ``JWT_ISSUER`` and any configured ``litellm_jwtauth.issuers``. 

2587 """ 

2588 import os # noqa: PLC0415 

2589 

2590 from litellm.proxy.proxy_server import general_settings # noqa: PLC0415 

2591 

2592 issuers: Final[list] = [] 

2593 env_issuer: Final = os.getenv("JWT_ISSUER") 

2594 if env_issuer: 

2595 issuers.append(env_issuer) 

2596 

2597 jwtauth: Final = general_settings.get("litellm_jwtauth") if isinstance(general_settings, Mapping) else None 

2598 raw_issuers: Final = jwtauth.get("issuers") if isinstance(jwtauth, dict) else getattr(jwtauth, "issuers", None) 

2599 for cfg in raw_issuers or []: 

2600 issuer = cfg.get("issuer") if isinstance(cfg, dict) else getattr(cfg, "issuer", None) 

2601 if issuer and issuer not in issuers: 

2602 issuers.append(issuer) 

2603 return issuers 

2604 

2605 

2606@router.get("/.well-known/oauth-protected-resource") 

2607def oauth_protected_resource_root(request: Request) -> dict[str, str | tuple[str, ...]]: 

2608 request_base_url: Final = get_request_base_url(request) 

2609 parsed: Final = urlparse(request_base_url) 

2610 return { 

2611 "resource": f"{parsed.scheme}://{parsed.netloc}", 

2612 "authorization_servers": (f"{request_base_url}/mcp",), 

2613 "scopes_supported": (), 

2614 } 

2615 

2616 

2617def _build_aggregate_protected_resource_response(request: Request) -> dict: 

2618 """RFC 9728 metadata for the aggregate /mcp resource: the gateway itself is 

2619 the authorization server. No per-server names or scopes leak here; access 

2620 is resolved after sign-in from the authenticated user's grants. 

2621 

2622 The advertised authorization server is ``{base}/mcp`` (not the bare 

2623 origin) so RFC 8414 path-insertion resolves its metadata at 

2624 ``/.well-known/oauth-authorization-server/mcp``, a route this module 

2625 owns. The bare-origin well-known is registered first by the BYOK OAuth 

2626 feature and describes the BYOK flow, so it must not be the aggregate 

2627 discovery entry point (same pattern as the per-server documents, which 

2628 advertise ``{base}/{server_name}``).""" 

2629 request_base_url: Final = get_request_base_url(request) 

2630 return { 

2631 "authorization_servers": [f"{request_base_url}/mcp"], 

2632 "resource": f"{request_base_url}/mcp", 

2633 "scopes_supported": [], 

2634 } 

2635 

2636 

2637def _build_aggregate_authorization_server_response( 

2638 request: Request, token_exchange_available: bool 

2639) -> dict[str, object]: 

2640 """RFC 8414 metadata for the gateway as the aggregate authorization server. 

2641 

2642 The issuer is ``{base}/mcp`` and must stay equal to the value the 

2643 aggregate protected-resource document advertises: spec clients verify the 

2644 issuer in the metadata matches the one that derived the well-known URL. 

2645 Advertises the MCP session authorize endpoint, root /token and /register endpoints, and 

2646 ``token_endpoint_auth_methods_supported: ["none", ...]`` because DCR 

2647 clients (Claude Desktop, MCP Inspector) register as public clients; PKCE 

2648 S256 is mandatory in the gateway's authorize flow.""" 

2649 request_base_url: Final = get_request_base_url(request) 

2650 return { 

2651 "issuer": f"{request_base_url}/mcp", 

2652 "authorization_endpoint": f"{request_base_url}/authorize/mcp-session", 

2653 "token_endpoint": f"{request_base_url}/token", 

2654 "introspection_endpoint": f"{request_base_url}/introspect", 

2655 "registration_endpoint": f"{request_base_url}/register", 

2656 "response_types_supported": ["code"], 

2657 "scopes_supported": [], 

2658 "grant_types_supported": supported_grant_types(token_exchange_available), 

2659 "code_challenge_methods_supported": ["S256"], 

2660 "token_endpoint_auth_methods_supported": ["none", "client_secret_post"], 

2661 } 

2662 

2663 

2664# RFC 9728 path-appended discovery for the aggregate /mcp endpoint. A client 

2665# pointed at {base}/mcp inserts the well-known segment before the resource 

2666# path, so this exact route must exist for aggregate discovery to work at all. 

2667# Declared before the parameterized well-known routes below: Starlette matches 

2668# in registration order, and /.well-known/oauth-authorization-server/{name} 

2669# would otherwise capture the "/mcp" suffix as a server name. 

2670@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp") 

2671async def oauth_protected_resource_aggregate(request: Request): 

2672 """ 

2673 OAuth protected resource discovery for the aggregate /mcp endpoint. 

2674 

2675 The single-segment ``/mcp`` path does not collide with any per-server PRM pattern 

2676 (those are two-segment: ``/mcp/{server}`` or ``/{server}/mcp``), so this unambiguously 

2677 describes the aggregate resource. 

2678 """ 

2679 return _build_aggregate_protected_resource_response(request) 

2680 

2681 

2682@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/mcp") 

2683async def oauth_authorization_server_aggregate(request: Request): 

2684 """ 

2685 OAuth authorization server discovery for the aggregate /mcp endpoint, the RFC 8414 

2686 path-inserted form for a client that treats {base}/mcp as its authorization base URL. 

2687 

2688 The single-segment /mcp is reserved for the aggregate so the discovery chain stays 

2689 consistent: the aggregate protected-resource document advertises {base}/mcp as its 

2690 authorization server, so the document served here must have issuer {base}/mcp. A server 

2691 literally named ``mcp`` therefore does not take this route; it keeps its standard 

2692 two-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp. Letting the 

2693 per-server row win here instead would serve an issuer of {base} against a resource that 

2694 advertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door. 

2695 """ 

2696 return _build_aggregate_authorization_server_response(request, token_exchange_available()) 

2697 

2698 

2699# Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name} 

2700# This is the pattern expected by standard MCP clients (mcp-inspector, VSCode Copilot) 

2701@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp/{{mcp_server_name}}") 

2702async def oauth_protected_resource_mcp_standard(request: Request, mcp_server_name: str): 

2703 """ 

2704 OAuth protected resource discovery endpoint using standard MCP URL pattern. 

2705 

2706 Standard pattern: /mcp/{server_name} 

2707 Discovery path: /.well-known/oauth-protected-resource/mcp/{server_name} 

2708 

2709 This endpoint is compliant with MCP specification and works with standard 

2710 MCP clients like mcp-inspector and VSCode Copilot. 

2711 """ 

2712 return await _build_oauth_protected_resource_response( 

2713 request=request, 

2714 mcp_server_name=mcp_server_name, 

2715 use_standard_pattern=True, 

2716 ) 

2717 

2718 

2719# LiteLLM legacy pattern: /.well-known/oauth-protected-resource/{server_name}/mcp 

2720# Kept for backward compatibility with existing deployments 

2721@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/{{mcp_server_name}}/mcp") 

2722async def oauth_protected_resource_mcp(request: Request, mcp_server_name: str | None = None): 

2723 """ 

2724 OAuth protected resource discovery endpoint using LiteLLM legacy URL pattern. 

2725 

2726 Legacy pattern: /{server_name}/mcp 

2727 Discovery path: /.well-known/oauth-protected-resource/{server_name}/mcp 

2728 

2729 This endpoint is kept for backward compatibility. New integrations should 

2730 use the standard MCP pattern (/mcp/{server_name}) instead. 

2731 """ 

2732 return await _build_oauth_protected_resource_response( 

2733 request=request, 

2734 mcp_server_name=mcp_server_name, 

2735 use_standard_pattern=False, 

2736 ) 

2737 

2738 

2739def _build_oauth_authorization_server_response( 

2740 request: Request, 

2741 mcp_server_name: str | None, 

2742 *, 

2743 issuer_path: str | None = None, 

2744) -> dict: 

2745 """Build OAuth authorization server metadata response (gateway-as-AS shape). 

2746 

2747 Synchronous because the body only does dict construction and synchronous 

2748 registry lookups; unlike :func:`_build_oauth_protected_resource_response` 

2749 it does not need to await any upstream IO. 

2750 """ 

2751 request_base_url: Final = get_request_base_url(request) 

2752 client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) 

2753 explicitly_named: Final = mcp_server_name is not None 

2754 

2755 # When no server name provided, try to resolve the single OAuth2 server 

2756 if mcp_server_name is None: 

2757 resolved: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) 

2758 if resolved: 2758 ↛ 2759line 2758 didn't jump to line 2759 because the condition on line 2758 was never true

2759 mcp_server_name = resolved.server_name or resolved.name 

2760 

2761 authorization_endpoint: Final = ( 

2762 f"{request_base_url}/{mcp_server_name}/authorize" if mcp_server_name else f"{request_base_url}/authorize" 

2763 ) 

2764 token_endpoint = f"{request_base_url}/{mcp_server_name}/token" if mcp_server_name else f"{request_base_url}/token" 

2765 

2766 mcp_server: MCPServer | None = None 

2767 if mcp_server_name: 

2768 mcp_server = _resolve_mcp_server_by_name_or_id(mcp_server_name, client_ip) 

2769 

2770 _raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth authorization server") 

2771 

2772 issuer: Final = ( 

2773 f"{request_base_url}/{issuer_path}" 

2774 if issuer_path is not None 

2775 else f"{request_base_url}/{mcp_server_name}" 

2776 if explicitly_named 

2777 else request_base_url 

2778 ) 

2779 

2780 return { 

2781 "issuer": issuer, 

2782 "authorization_endpoint": authorization_endpoint, 

2783 "token_endpoint": token_endpoint, 

2784 "response_types_supported": ["code"], 

2785 "scopes_supported": (mcp_server.scopes if mcp_server and mcp_server.scopes else []), 

2786 "grant_types_supported": ["authorization_code", "refresh_token"], 

2787 "code_challenge_methods_supported": ["S256"], 

2788 "token_endpoint_auth_methods_supported": ["client_secret_post"], 

2789 # Claude expects a registration endpoint, even if we just fake it 

2790 "registration_endpoint": ( 

2791 f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register" 

2792 ), 

2793 } 

2794 

2795 

2796# Standard MCP pattern: /.well-known/oauth-authorization-server/mcp/{server_name} 

2797@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/mcp/{{mcp_server_name}}") 

2798async def oauth_authorization_server_mcp_standard(request: Request, mcp_server_name: str): 

2799 """ 

2800 OAuth authorization server discovery endpoint using standard MCP URL pattern. 

2801 

2802 Standard pattern: /mcp/{server_name} 

2803 Discovery path: /.well-known/oauth-authorization-server/mcp/{server_name} 

2804 """ 

2805 return _build_oauth_authorization_server_response( 

2806 request=request, 

2807 mcp_server_name=mcp_server_name, 

2808 issuer_path=f"mcp/{mcp_server_name}", 

2809 ) 

2810 

2811 

2812# LiteLLM legacy pattern and root endpoint 

2813@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/{{mcp_server_name}}") 

2814@router.get("/.well-known/oauth-authorization-server") 

2815async def oauth_authorization_server_mcp(request: Request, mcp_server_name: str | None = None): 

2816 """ 

2817 OAuth authorization server discovery endpoint. 

2818 

2819 Supports both legacy pattern (/{server_name}) and root endpoint. 

2820 """ 

2821 return _build_oauth_authorization_server_response( 

2822 request=request, 

2823 mcp_server_name=mcp_server_name, 

2824 ) 

2825 

2826 

2827# Alias for standard OpenID discovery 

2828@router.get("/.well-known/openid-configuration") 

2829async def openid_configuration(request: Request): 

2830 response = await oauth_authorization_server_mcp(request) 

2831 

2832 # If MCPJWTSigner is active, augment the discovery doc with JWKS fields so 

2833 # MCP servers and gateways (e.g. AWS Bedrock AgentCore Gateway) can resolve 

2834 # the signing keys and verify liteLLM-issued tokens. 

2835 try: 

2836 from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( 

2837 get_mcp_jwt_signer, 

2838 ) 

2839 

2840 signer: Final = get_mcp_jwt_signer() 

2841 if signer is not None: 2841 ↛ 2842line 2841 didn't jump to line 2842 because the condition on line 2841 was never true

2842 request_base_url: Final = get_request_base_url(request) 

2843 if isinstance(response, dict): 

2844 response = { 

2845 **response, 

2846 "jwks_uri": f"{request_base_url}/.well-known/jwks.json", 

2847 "id_token_signing_alg_values_supported": ["RS256"], 

2848 } 

2849 except ImportError: 

2850 pass 

2851 

2852 return response 

2853 

2854 

2855@router.get("/.well-known/jwks.json") 

2856async def jwks_json(request: Request): 

2857 """ 

2858 JSON Web Key Set endpoint. 

2859 

2860 Returns the RSA public key used by MCPJWTSigner to sign outbound MCP tokens. 

2861 MCP servers and gateways use this endpoint to verify liteLLM-issued JWTs. 

2862 

2863 Returns an empty key set if MCPJWTSigner is not configured. 

2864 """ 

2865 try: 

2866 from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( 

2867 get_mcp_jwt_signer, 

2868 ) 

2869 

2870 signer: Final = get_mcp_jwt_signer() 

2871 if signer is not None: 2871 ↛ 2872line 2871 didn't jump to line 2872 because the condition on line 2871 was never true

2872 return JSONResponse( 

2873 content=signer.get_jwks(), 

2874 headers={"Cache-Control": f"public, max-age={signer.jwks_max_age}"}, 

2875 ) 

2876 except ImportError: 

2877 pass 

2878 

2879 # No signer active — return empty key set; short cache so activation is picked up quickly. 

2880 return JSONResponse( 

2881 content={"keys": []}, 

2882 headers={"Cache-Control": "public, max-age=60"}, 

2883 ) 

2884 

2885 

2886# Additional legacy pattern support 

2887@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/{{mcp_server_name}}/mcp") 

2888async def oauth_authorization_server_legacy(request: Request, mcp_server_name: str): 

2889 """ 

2890 OAuth authorization server discovery for legacy /{server_name}/mcp pattern. 

2891 """ 

2892 return _build_oauth_authorization_server_response( 

2893 request=request, 

2894 mcp_server_name=mcp_server_name, 

2895 issuer_path=f"{mcp_server_name}/mcp", 

2896 ) 

2897 

2898 

2899@router.post("/{mcp_server_name}/register") 

2900@router.post("/register") 

2901async def register_client(request: Request, mcp_server_name: str | None = None): 

2902 # Get the correct base URL considering X-Forwarded-* headers 

2903 request_base_url: Final = get_request_base_url(request) 

2904 

2905 request_data: Final = await _read_request_body(request=request) 

2906 data: Final[dict] = {**request_data} 

2907 client_redirect_uris: Final = client_supplied_redirect_uris(data.get("redirect_uris")) 

2908 

2909 dummy_return: Final = { 

2910 "client_id": mcp_server_name or "dummy_client", 

2911 "client_secret": "dummy", 

2912 "redirect_uris": client_redirect_uris or [f"{request_base_url}/callback"], 

2913 } 

2914 client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) 

2915 if not mcp_server_name: 

2916 # A real DCR request carries redirect_uris (RFC 7591): route it to the aggregate DCR 

2917 # endpoint the aggregate authorization-server metadata advertises. A single-server 

2918 # deployment registers at /{server}/register instead (its bare-origin discovery 

2919 # advertises that), so this does not affect it. A request without redirect_uris is not 

2920 # a DCR request, so the legacy single-server-or-dummy fallback is kept for it. 

2921 if data.get("redirect_uris"): 2921 ↛ 2922line 2921 didn't jump to line 2922 because the condition on line 2921 was never true

2922 return await register_aggregate_client( 

2923 request=request, request_body=data, token_exchange_available=token_exchange_available() 

2924 ) 

2925 resolved: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) 

2926 if resolved: 2926 ↛ 2927line 2926 didn't jump to line 2927 because the condition on line 2926 was never true

2927 return await register_client_with_server( 

2928 request=request, 

2929 mcp_server=resolved, 

2930 client_name=data.get("client_name", ""), 

2931 grant_types=data.get("grant_types", []), 

2932 response_types=data.get("response_types", []), 

2933 token_endpoint_auth_method=data.get("token_endpoint_auth_method", ""), 

2934 fallback_client_id=resolved.server_name or resolved.name, 

2935 client_redirect_uris=client_redirect_uris, 

2936 ) 

2937 return dummy_return 

2938 

2939 mcp_server: Final = _resolve_mcp_server_by_name_or_id(mcp_server_name, client_ip) 

2940 if mcp_server is None: 2940 ↛ 2942line 2940 didn't jump to line 2942 because the condition on line 2940 was always true

2941 return dummy_return 

2942 return await register_client_with_server( 

2943 request=request, 

2944 mcp_server=mcp_server, 

2945 client_name=data.get("client_name", ""), 

2946 grant_types=data.get("grant_types", []), 

2947 response_types=data.get("response_types", []), 

2948 token_endpoint_auth_method=data.get("token_endpoint_auth_method", ""), 

2949 fallback_client_id=mcp_server_name, 

2950 client_redirect_uris=client_redirect_uris, 

2951 )