Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py: 42%
175 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
1"""The v1 <-> v2 bridge for the credential resolver.
3These edge functions translate v1's request objects into the resolver's typed inputs and map
4its typed errors onto the proxy's public exception contract. They import v1 and live outside the
5package's public surface so the resolver core (``resolver.py`` / ``types.py``) stays v1-free.
6Nothing wires them into ``_create_mcp_client`` yet.
8``to_server_spec`` maps only the modes the resolver has gone live for, returning ``None`` for
9every other mode so the caller defers to v1 (parity-safe); it grows one branch per migrated mode.
10"""
12from __future__ import annotations
14import base64
15import os
16from collections.abc import Iterable, Mapping
17from typing import TYPE_CHECKING, Final, Literal, NoReturn
19from fastapi import HTTPException
20from pydantic import SecretStr
21from typing_extensions import assert_never
23from litellm.experimental_mcp_client.client import MCPClient, strip_auth_scheme, to_basic_credentials
24from litellm.proxy._experimental.mcp_server.exceptions import MCPServerURLCredentialsError
25from litellm.proxy._experimental.mcp_server.oauth_utils import resolve_upstream_resource
26from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error, Ok, Result
27from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
28 DEFAULT_CREDENTIAL_HEADER,
29 ApiKeyConfig,
30 AuthorizationCodeConfig,
31 ClientAuth,
32 ClientCredentialsConfig,
33 ClientSecretAuth,
34 CredError,
35 IdJagConfig,
36 NoneConfig,
37 PassthroughConfig,
38 PrivateKeyJwtAuth,
39 ServerSpec,
40 SharedKey,
41 Subject,
42 TokenExchangeConfig,
43)
44from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth, MCPAuthType, MCPTransport
46if TYPE_CHECKING: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true
47 from litellm.proxy._types import UserAPIKeyAuth
48 from litellm.types.mcp_server.mcp_server_manager import MCPServer
50_TOKEN_EXCHANGE_SUBJECT_TOKEN_DEFAULT: Final = "urn:ietf:params:oauth:token-type:access_token"
51_ID_JAG_SUBJECT_TOKEN_DEFAULT: Final = "urn:ietf:params:oauth:token-type:id_token"
54def token_header(server: MCPServer) -> str:
55 """The upstream header this server's resolved credential occupies.
57 One owner for every arm, so no spec builder spells the default itself and a server can never
58 hand two arms different answers.
59 """
60 return server.upstream_token_header or DEFAULT_CREDENTIAL_HEADER
63def to_subject(user_api_key_auth: UserAPIKeyAuth | None, subject_token: str | None) -> Subject:
64 """Map v1's authenticated principal onto the resolver's Subject.
66 tenant_id / subject_id are empty for an unauthenticated caller; the per-user arms must reject
67 an empty subject rather than share one credential slot across callers.
68 """
69 inbound: Final = SecretStr(subject_token) if subject_token else None
70 if user_api_key_auth is None:
71 return Subject(tenant_id="", subject_id="", inbound_token=inbound)
72 return Subject(
73 tenant_id=user_api_key_auth.org_id or user_api_key_auth.team_id or "",
74 subject_id=user_api_key_auth.user_id or "",
75 inbound_token=inbound,
76 )
79def to_server_spec(server: MCPServer) -> ServerSpec | None:
80 """Map a v1 server onto a ServerSpec for a migrated mode, or None to defer to v1.
82 BYOK is the per-user source of the ``api_key`` mode; its scheme rides on ``auth_type`` just
83 like a shared key, but the value is per-user and not migrated yet, so a BYOK server defers
84 to v1 for its static schemes. Declared OBO always stays with the exchange arm.
86 Dispatches on the declared ``auth_type``. The match is exhaustive over ``MCPAuthType`` with
87 an ``assert_never`` tail, so a newly added auth mode fails the type gate here until it is
88 explicitly mapped or explicitly deferred, rather than silently falling through to v1. Live
89 modes: ``none``, the static-header family (``api_key`` plus the Authorization schemes,
90 all shared-key), ``oauth2`` per-user tokens (``authorization_code``), ``oauth2`` M2M
91 (``client_credentials``), ``oauth2_token_exchange`` (OBO), and the client-forwarded token
92 modes ``true_passthrough`` / ``oauth_delegate`` (``PassthroughConfig``); delegated/passthrough
93 oauth2 and SigV4 return None and stay on v1.
94 """
95 if server.is_byok and server.auth_type != MCPAuth.oauth2_token_exchange:
96 return None # per-user BYOK source not migrated yet -> defer to v1
97 resource: Final = server.url or server.server_id
98 auth_type: Final = server.auth_type
99 match auth_type:
100 case None | MCPAuth.none:
101 if server.is_oauth_passthrough: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 return None # passthrough is not migrated yet -> defer to v1
103 return ServerSpec(server_id=server.server_id, resource=resource, config=NoneConfig())
104 case MCPAuth.api_key: 104 ↛ 105line 104 didn't jump to line 105 because the pattern on line 104 never matched
105 return _shared_key_spec(server, resource, "X-API-Key", "")
106 case MCPAuth.bearer_token:
107 return _shared_key_spec(server, resource, "Authorization", "Bearer")
108 case MCPAuth.token: 108 ↛ 109line 108 didn't jump to line 109 because the pattern on line 108 never matched
109 return _shared_key_spec(server, resource, "Authorization", "token")
110 case MCPAuth.authorization:
111 return _shared_key_spec(server, resource, "Authorization", "")
112 case MCPAuth.basic: 112 ↛ 113line 112 didn't jump to line 113 because the pattern on line 112 never matched
113 return _shared_key_spec(server, resource, "Authorization", "Basic", encode=True)
114 case MCPAuth.oauth2: 114 ↛ 115line 114 didn't jump to line 115 because the pattern on line 114 never matched
115 return _oauth2_spec(server, resource)
116 case MCPAuth.oauth2_id_jag: 116 ↛ 117line 116 didn't jump to line 117 because the pattern on line 116 never matched
117 return _id_jag_spec(server, resource)
118 case MCPAuth.true_passthrough | MCPAuth.oauth_delegate:
119 return ServerSpec(server_id=server.server_id, resource=resource, config=PassthroughConfig())
120 case MCPAuth.oauth2_token_exchange: 120 ↛ 121line 120 didn't jump to line 121 because the pattern on line 120 never matched
121 return _token_exchange_spec(server, resource)
122 case MCPAuth.aws_sigv4: 122 ↛ 124line 122 didn't jump to line 124 because the pattern on line 122 always matched
123 return None # SigV4 is not migrated yet -> defer to v1
124 assert_never(auth_type)
127def _oauth2_spec(server: MCPServer, resource: str) -> ServerSpec | None:
128 """Dispatch the oauth2 auth_type across its sub-modes: M2M, gateway-managed interactive, or v1.
130 ``client_credentials`` (the explicit ``oauth2_flow`` opt-in) builds the M2M spec, per-user
131 ``authorization_code`` without upstream delegation builds the interactive spec, and the
132 delegate/passthrough shapes defer to v1 (None).
133 """
134 if server.has_client_credentials:
135 return _client_credentials_spec(server, resource)
136 if server.needs_user_oauth_token and not server.delegate_auth_to_upstream:
137 return ServerSpec(
138 server_id=server.server_id,
139 resource=resource,
140 config=AuthorizationCodeConfig(header_name=token_header(server)),
141 )
142 return None
145def _client_credentials_spec(server: MCPServer, resource: str) -> ServerSpec:
146 """Build a client_credentials (M2M) spec; the explicit ``oauth2_flow`` opt-in owns the server.
148 Missing grant fields (``client_id``/``client_secret``/``token_url``) are NOT a reason to defer:
149 v1 would connect unauthenticated and the upstream's 401 gets absorbed into an empty tool list,
150 so the arm fails closed with ``misconfigured`` instead, naming the missing fields (mirrors the
151 OBO ownership rule). ``audience`` is forwarded only when the operator set it; a missing one is
152 omitted, not derived, since a fabricated value risks the IdP rejecting the grant.
153 """
154 return ServerSpec(
155 server_id=server.server_id,
156 resource=resource,
157 config=ClientCredentialsConfig(
158 header_name=token_header(server),
159 client_id=server.client_id,
160 client_secret=SecretStr(server.client_secret) if server.client_secret else None,
161 token_url=server.effective_token_url,
162 scopes=tuple(server.scopes or ()),
163 audience=server.audience,
164 upstream_resource=resolve_upstream_resource(server),
165 token_endpoint_auth_method=server.token_endpoint_auth_method,
166 ),
167 )
170def _token_exchange_spec(server: MCPServer, resource: str) -> ServerSpec:
171 """Keep declared OBO owned by the resolver, including incomplete client configuration."""
172 endpoint: Final = server.token_exchange_endpoint or server.effective_token_url
173 profile: Final[Literal["rfc8693", "entra_obo"]] = (
174 "entra_obo" if server.token_exchange_profile == "entra_obo" else "rfc8693"
175 )
176 return ServerSpec(
177 server_id=server.server_id,
178 resource=resource,
179 config=TokenExchangeConfig(
180 header_name=token_header(server),
181 profile=profile,
182 subject_token_type=server.subject_token_type or DEFAULT_SUBJECT_TOKEN_TYPE,
183 token_exchange_endpoint=endpoint,
184 audience=server.audience,
185 client_id=server.client_id,
186 client_secret=SecretStr(server.client_secret) if server.client_secret else None,
187 token_endpoint_auth_method=server.token_endpoint_auth_method,
188 scopes=tuple(server.scopes or ()),
189 ),
190 )
193def _shared_key_spec(
194 server: MCPServer,
195 resource: str,
196 header_name: str,
197 value_prefix: str,
198 *,
199 encode: bool = False,
200) -> ServerSpec | None:
201 """Build an api_key spec from the server's static token, or defer (None) if it is absent.
203 Covers the whole shared-key static-header family: ``api_key`` on ``X-API-Key`` and the
204 Authorization schemes (bearer / token / authorization sent verbatim, basic base64-encoded).
205 """
206 token: Final = server.authentication_token
207 if not token: 207 ↛ 209line 207 didn't jump to line 209 because the condition on line 207 was always true
208 return None # no key configured -> defer to v1 (parity-safe)
209 value: Final = (
210 to_basic_credentials(token) if encode else strip_auth_scheme(token, value_prefix) if value_prefix else token
211 )
212 return ServerSpec(
213 server_id=server.server_id,
214 resource=resource,
215 config=ApiKeyConfig(
216 header_name=server.upstream_token_header or header_name,
217 value_prefix=value_prefix,
218 key_source=SharedKey(value=SecretStr(value)),
219 ),
220 )
223def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None:
224 """Build an ID-JAG spec from the v1 server's raw fields, or defer (None) if half-configured.
226 The enum already routes here, but a server missing an endpoint, ``client_id``, or any client-auth
227 secret would make ``IdJagConfig`` raise at construction; returning None instead defers to v1 so a
228 partially configured server does not 500. ``token_exchange_endpoint`` is leg 1 (the IdP org AS);
229 leg 2 is ``id_jag_resource_token_endpoint`` (the upstream resource AS).
230 """
231 org_token_endpoint: Final = server.token_exchange_endpoint
232 resource_token_endpoint: Final = server.id_jag_resource_token_endpoint
233 client_id: Final = server.client_id
234 client_auth: Final = _id_jag_client_auth(server)
235 if not org_token_endpoint or not resource_token_endpoint or not client_id or client_auth is None:
236 return None
237 return ServerSpec(
238 server_id=server.server_id,
239 resource=resource,
240 config=IdJagConfig(
241 header_name=token_header(server),
242 org_token_endpoint=org_token_endpoint,
243 resource_token_endpoint=resource_token_endpoint,
244 client_id=client_id,
245 client_auth=client_auth,
246 subject_token_type=_id_jag_subject_token_type(server),
247 audience=server.audience,
248 resource=server.id_jag_resource,
249 scopes=tuple(server.scopes or ()),
250 ),
251 )
254def _id_jag_client_auth(server: MCPServer) -> ClientAuth | None:
255 """Private-key JWT when a key is configured, else client_secret, else None (defer to v1)."""
256 if server.client_private_key:
257 return PrivateKeyJwtAuth(
258 private_key=SecretStr(server.client_private_key),
259 key_id=server.client_private_key_id,
260 signing_alg=server.client_assertion_signing_alg,
261 )
262 if server.client_secret:
263 return ClientSecretAuth(client_secret=SecretStr(server.client_secret))
264 return None
267def _id_jag_subject_token_type(server: MCPServer) -> str:
268 """ID-JAG asserts the user's id_token, so the token-exchange access_token default maps to id_token;
269 an explicitly configured value (e.g. a SAML2 assertion type) is honored verbatim."""
270 configured: Final = server.subject_token_type
271 if configured and configured != _TOKEN_EXCHANGE_SUBJECT_TOKEN_DEFAULT:
272 return configured
273 return _ID_JAG_SUBJECT_TOKEN_DEFAULT
276def raise_public(error: CredError) -> NoReturn:
277 """Map a resolver CredError onto the proxy's public HTTP contract. The one edge that raises."""
278 match error.tag:
279 case "unauthorized": 279 ↛ 280line 279 didn't jump to line 280 because the pattern on line 279 never matched
280 challenge: Final = error.unauthorized
281 raise HTTPException(
282 status_code=401,
283 detail=challenge.body if challenge.body is not None else error.summary,
284 headers=({"WWW-Authenticate": challenge.www_authenticate} if challenge.www_authenticate else None),
285 )
286 case "misconfigured": 286 ↛ 288line 286 didn't jump to line 288 because the pattern on line 286 always matched
287 raise HTTPException(status_code=500, detail=error.summary)
288 case "url_credentials_not_allowed":
289 raise MCPServerURLCredentialsError()
290 case "upstream_unavailable":
291 raise HTTPException(status_code=503, detail=error.summary)
292 case "unsupported_mode":
293 raise HTTPException(status_code=500, detail=error.summary)
294 case "precondition_required":
295 raise HTTPException(status_code=412, detail=error.summary)
296 case "not_implemented":
297 raise HTTPException(status_code=501, detail=error.summary)
298 assert_never(error.tag)
301def oauth_protected_resource_path(root_path: str, server: MCPServer) -> str:
302 """The server's RFC 9728 Protected Resource Metadata path, the shared anchor of both challenges.
304 ``root_path`` is the prefix the request was routed under, resolved by the caller (the imperative
305 shell); ``"/"`` and ``""`` both mean no prefix. The path is relative, so it resolves against the
306 caller's own host (correct even behind a reverse proxy).
308 URL structure depends on how the prefix is served:
310 - The scalar ``SERVER_ROOT_PATH`` deployment registers the well-known routes with the prefix
311 *inserted* into the path (via :func:`well_known_root_suffix` at import time), matching RFC 8414
312 §3 well-known path insertion. When ``root_path`` equals ``SERVER_ROOT_PATH`` the URL must use
313 the same insertion or a client fetching it 404s.
314 - The per-request ``SERVER_ROOT_PATHS`` deployment can't register routes per prefix (the prefix
315 set is dynamic and could contain many entries); the middleware strips the prefix from
316 ``scope["path"]`` and the router matches the un-inserted well-known route. The URL must place
317 the prefix *before* ``.well-known`` so the strip leaves a matching path.
319 Picking the wrong form 404s the client's discovery fetch — the discovery document and the 401
320 challenge would then disagree on where the resource metadata lives.
321 """
322 prefix: Final = "" if root_path == "/" else root_path
323 name: Final = server.alias or server.server_name or server.name or server.server_id
324 scalar_env: Final = os.getenv("SERVER_ROOT_PATH", "").rstrip("/")
325 if not prefix or (scalar_env and prefix == scalar_env):
326 return f"/.well-known/oauth-protected-resource{prefix}/mcp/{name}"
327 return f"{prefix}/.well-known/oauth-protected-resource/mcp/{name}"
330def raise_user_oauth_challenge(server: MCPServer, *, root_path: str) -> NoReturn:
331 """Raise the 401 an ``authorization_code`` server returns at egress when the user has no token.
333 Points at the server's RFC 9728 Protected Resource Metadata, which names the upstream
334 authorization server the client must complete OAuth with. The listing-phase 401 still emits the
335 RFC 8414 ``authorization_uri`` form pending the format unification; both target the same server,
336 so the difference is cosmetic.
337 """
338 resource_metadata: Final = oauth_protected_resource_path(root_path, server)
339 raise HTTPException(
340 status_code=401,
341 detail="Unauthorized",
342 headers={"WWW-Authenticate": f'Bearer resource_metadata="{resource_metadata}"'},
343 )
346def raise_token_exchange_challenge(
347 server: MCPServer,
348 *,
349 root_path: str,
350 claims: str | None = None,
351) -> NoReturn:
352 """Raise the RFC 9728 / RFC 6750 challenge an OBO (``token_exchange``) server returns when the
353 caller's subject token is missing or the IdP rejected it.
355 Points at the server's Protected Resource Metadata, whose ``authorization_servers`` names the IdP
356 the client must SSO with to obtain a subject token; ``error="invalid_token"`` tells a
357 spec-compliant MCP client to discover that AS and retry with a fresh bearer. Mirrors
358 ``raise_user_oauth_challenge`` but for the exchange flow: there is no gateway-side browser OAuth —
359 the client re-authenticates directly with the IdP, and LiteLLM then exchanges the resulting token.
361 An IdP step-up rejection (Entra Conditional Access / CAE) passes its ``claims`` blob. Per the
362 Microsoft claims-challenge format the challenge then uses ``error="insufficient_claims"`` (the
363 value MSAL-family clients key on) and carries the claims base64-encoded in a ``claims`` parameter
364 the client replays to the IdP to satisfy the step-up. Without a claims blob the challenge keeps
365 ``error="invalid_token"`` and is byte-identical to the static one. Both the error value (one of
366 two literals) and the base64 claims draw from a fixed alphabet, so nothing from the IdP body
367 reaches the header unescaped.
368 """
369 resource_metadata: Final = oauth_protected_resource_path(root_path, server)
370 encoded_claims: Final = base64.b64encode(claims.encode()).decode() if claims else None
371 error: Final = "insufficient_claims" if encoded_claims else "invalid_token"
372 error_description: Final = (
373 "Step-up authentication required; satisfy the returned claims challenge with the IdP and retry"
374 if encoded_claims
375 else "Missing or invalid subject token; authenticate with the IdP and retry"
376 )
377 www_authenticate: Final = ", ".join(
378 (
379 f'Bearer resource_metadata="{resource_metadata}"',
380 f'error="{error}"',
381 f'error_description="{error_description}"',
382 *((f'claims="{encoded_claims}"',) if encoded_claims else ()),
383 )
384 )
385 raise HTTPException(
386 status_code=401,
387 detail="Unauthorized",
388 headers={"WWW-Authenticate": www_authenticate},
389 )
392_STATIC_MODES: Final = frozenset(
393 (MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic, MCPAuth.token, MCPAuth.authorization)
394)
397def _usable_credential_value(auth_type: MCPAuthType, name: str, value: str) -> bool:
398 if not value:
399 return False
400 if auth_type == MCPAuth.api_key and name != "authorization":
401 return True
402 if value.lower() in ("bearer", "basic", "token", "apikey"):
403 return False
404 if auth_type == MCPAuth.api_key:
405 api_scheme: Final = value.split(None, 1)[0]
406 if api_scheme.lower() in ("bearer", "token", "apikey"):
407 api_credential: Final = strip_auth_scheme(value, api_scheme).strip()
408 return api_credential.lower() != api_scheme.lower()
409 if auth_type in (MCPAuth.bearer_token, MCPAuth.token):
410 scheme: Final = "Bearer" if auth_type == MCPAuth.bearer_token else "token"
411 credential: Final = strip_auth_scheme(value, scheme).strip()
412 return bool(credential) and credential.lower() != scheme.lower()
413 if auth_type == MCPAuth.basic:
414 parts: Final = value.split(None, 1)
415 if len(parts) != 2 or parts[0].lower() != "basic":
416 return False
417 try:
418 decoded: Final = base64.b64decode(parts[1], validate=True).strip()
419 return b":" in decoded
420 except ValueError:
421 return False
422 return True
425def validate_static_credential(
426 auth_type: MCPAuthType,
427 headers: Mapping[str, str],
428 upstream_token_header: str | None = None,
429 static_header_names: Iterable[str] = (),
430) -> Result[None, CredError]:
431 if auth_type not in _STATIC_MODES: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 return Ok(None)
433 default_slot: Final = "X-API-Key" if auth_type == MCPAuth.api_key else "Authorization"
434 admin_chosen_slots: Final = tuple(static_header_names) if auth_type == MCPAuth.api_key else ()
435 slots: Final = frozenset(
436 name.lower()
437 for name in (
438 upstream_token_header or default_slot,
439 default_slot,
440 "Authorization",
441 *admin_chosen_slots,
442 )
443 )
444 values: Final = tuple((name.lower(), value.strip()) for name, value in headers.items() if name.lower() in slots)
445 if any(_usable_credential_value(auth_type, name, value) for name, value in values): 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true
446 return Ok(None)
447 return Error(CredError.of_misconfigured(f"{auth_type} requires a usable upstream credential"))
450async def prepare_mcp_client(server: MCPServer, client: MCPClient) -> MCPClient:
451 if server.auth_type not in _STATIC_MODES or client.transport_type == MCPTransport.stdio:
452 return client
453 request: Final = await client.prepare_request_auth()
454 match validate_static_credential(
455 server.auth_type, request.headers, server.upstream_token_header, server.static_headers or ()
456 ):
457 case Error(error): 457 ↛ 459line 457 didn't jump to line 459 because the pattern on line 457 always matched
458 raise_public(error)
459 case Ok():
460 return client