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
« 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
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
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
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
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]] = {}
111router: Final = APIRouter(
112 tags=["mcp"],
113)
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)
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)
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)
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.
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
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
202def decode_state_hash(encrypted_state: str) -> dict:
203 """
204 Decode an encrypted state to retrieve all OAuth session data.
206 Args:
207 encrypted_state: The encrypted string to decode
209 Returns:
210 A dict containing base_url, original_state, and optional PKCE parameters
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")
219 state_data: Final = json.loads(decrypted_json)
220 return state_data
223_BRIDGE_AUTH_CODE_PREFIX: Final = "llm_bcode_"
226class _BridgeAuthorizationCode(BaseModel):
227 """Authenticated caller and upstream code sealed for bridge or identity-bound per-user OAuth."""
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)
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)
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)
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
285_PASSTHROUGH_AUTH_CODE_PREFIX: Final = "llm_ptcode_"
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."""
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)
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)
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
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
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 )
380 return _user_id_from_session_cookie(request)
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)})}")
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
405def _oauth_state_cookie_name(relay_state: str) -> str:
406 return f"{_OAUTH_STATE_COOKIE_PREFIX}{relay_state}"
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"
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 )
432def _resolve_encoded_oauth_state(request: Request, state: str) -> str:
433 """Return the encrypted OAuth session for a ``/callback`` request.
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
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 )
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
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)))
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 )
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)
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).
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 )
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
509def _normalize_for_token_comparison(value: object) -> str:
510 """Stringify ``value`` for token-rule comparison.
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)
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.
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 )
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.
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
588 access_token: Final[str | None] = token_response.get("access_token")
589 if not access_token:
590 return
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
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
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 )
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
632 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415
633 global_mcp_server_manager,
634 )
636 await global_mcp_server_manager.invalidate_user_oauth_token_cache(user_id, server.server_id)
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 )
649def _raise_if_not_oauth2(mcp_server: MCPServer) -> None:
650 """Reject a server without upstream OAuth from the gateway's authorize/token/register flow.
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 )
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 )
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 )
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.
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 )
725 return await global_mcp_server_manager.ensure_oauth_metadata_discovered(mcp_server)
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.
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 )
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
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 )
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))))
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)
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 )
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)
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 )
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
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 )
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)
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 )
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)
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
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)
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)
974 if enforce_binding and "openid" not in params.get("scope", "").split():
975 params["scope"] = f"openid {params.get('scope', '')}".strip()
977 if code_challenge:
978 params["code_challenge"] = code_challenge
979 if code_challenge_method:
980 params["code_challenge_method"] = code_challenge_method
982 upstream_resource: Final = resolve_upstream_resource(resolved_server)
983 if upstream_resource:
984 params["resource"] = upstream_resource
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
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"
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")
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 )
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
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 )
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
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
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 )
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()
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 )
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 )
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
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 )
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)
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"))
1293 result: Final = {
1294 "access_token": raw_access_token,
1295 "token_type": token_response.get("token_type", "Bearer"),
1296 }
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"]
1305 # RFC 6749 §5.1: token responses must not be cached.
1306 return JSONResponse(result, headers=TOKEN_NO_CACHE_HEADERS)
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."""
1313 client_id: str
1314 client_secret: str | None = None
1315 token_endpoint_auth_method: str | None = None
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
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.
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
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
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 )
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
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
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
1396 credentials: Final = _get_persisted_dcr_credentials(blob)
1397 if credentials is None or not credentials.client_id:
1398 return None
1399 return credentials
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)
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
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
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
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
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 )
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)
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.
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
1514DcrRegistrationPersistenceResult = Literal["persisted", "reused", "skipped", "failed"]
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.
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.
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.
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"
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"
1559 if await _reuse_persisted_dcr_client_if_available(mcp_server, current_redirect_uri=current_redirect_uri):
1560 return "reused"
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 }
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
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"
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
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
1664class EphemeralDcrClient(BaseModel):
1665 """A DCR client minted for a single authorize round trip and never stored by the gateway."""
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
1673_EPHEMERAL_DCR_CLIENT_CACHE: Final = InMemoryCache(default_ttl=_OAUTH_STATE_COOKIE_TTL_SECONDS)
1674_EPHEMERAL_DCR_MINT_LOCKS: Final[dict[str, asyncio.Lock]] = {}
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.
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
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)
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
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
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 }
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
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
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 )
1839 registration_url: Final = resolved_server.effective_registration_url
1840 if registration_url is None:
1841 return dummy_return
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 )
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 )
1863 token_response = response.json()
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
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}
1875 return JSONResponse(token_response)
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 )
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 )
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 )
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.
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 )
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 )
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 )
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.
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
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"
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 )
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
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 )
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 )
2120 return await revoke_refresh_token(token=token, client_id=client_id, master_key=master_key, cache=user_api_key_cache)
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 )
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 )
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 )
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).
2162def _render_oauth_error_html(error: str, description: str | None) -> HTMLResponse:
2163 """Render an actionable HTML page for an IdP-reported OAuth error.
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)
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.
2195 Accepts either:
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
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
2240 # No state — nothing to round-trip to. Show the user the error.
2241 return _render_oauth_error_html(error, error_description)
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 )
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"]
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)
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 )
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
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
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
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)
2328 The resource URL returned matches the pattern used in the discovery request.
2329"""
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.
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.
2342 Responses are cached in-process for ~5 minutes keyed on
2343 ``(server_id, resource_url)`` so we do not hammer the IdP.
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
2352 upstream: Final = urlparse(mcp_server.url)
2353 if not upstream.scheme or not upstream.netloc:
2354 return None
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]
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]
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('/')}")
2376 async_client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
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
2418 if len(network_errors) == len(candidates):
2419 raise network_errors[-1]
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
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)
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.
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.
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``).
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
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)
2476 request_base_url: Final = get_request_base_url(request)
2477 client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
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)
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"
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 }
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 )
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}
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 )
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
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 }
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")
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 }
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.
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 }
2581def _jwt_auth_issuers() -> list:
2582 """The OAuth issuer identifier(s) LiteLLM's JWT auth trusts, for the OBO PRM authorization_servers.
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
2590 from litellm.proxy.proxy_server import general_settings # noqa: PLC0415
2592 issuers: Final[list] = []
2593 env_issuer: Final = os.getenv("JWT_ISSUER")
2594 if env_issuer:
2595 issuers.append(env_issuer)
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
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 }
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.
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 }
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.
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 }
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.
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)
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.
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())
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.
2706 Standard pattern: /mcp/{server_name}
2707 Discovery path: /.well-known/oauth-protected-resource/mcp/{server_name}
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 )
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.
2726 Legacy pattern: /{server_name}/mcp
2727 Discovery path: /.well-known/oauth-protected-resource/{server_name}/mcp
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 )
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).
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
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
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"
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)
2770 _raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth authorization server")
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 )
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 }
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.
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 )
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.
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 )
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)
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 )
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
2852 return response
2855@router.get("/.well-known/jwks.json")
2856async def jwks_json(request: Request):
2857 """
2858 JSON Web Key Set endpoint.
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.
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 )
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
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 )
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 )
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)
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"))
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
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 )