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

1"""The v1 <-> v2 bridge for the credential resolver. 

2 

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. 

7 

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

11 

12from __future__ import annotations 

13 

14import base64 

15import os 

16from collections.abc import Iterable, Mapping 

17from typing import TYPE_CHECKING, Final, Literal, NoReturn 

18 

19from fastapi import HTTPException 

20from pydantic import SecretStr 

21from typing_extensions import assert_never 

22 

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 

45 

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 

49 

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" 

52 

53 

54def token_header(server: MCPServer) -> str: 

55 """The upstream header this server's resolved credential occupies. 

56 

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 

61 

62 

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. 

65 

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 ) 

77 

78 

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. 

81 

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. 

85 

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) 

125 

126 

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. 

129 

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 

143 

144 

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. 

147 

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 ) 

168 

169 

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 ) 

191 

192 

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. 

202 

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 ) 

221 

222 

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. 

225 

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 ) 

252 

253 

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 

265 

266 

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 

274 

275 

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) 

299 

300 

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. 

303 

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

307 

308 URL structure depends on how the prefix is served: 

309 

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. 

318 

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

328 

329 

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. 

332 

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 ) 

344 

345 

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. 

354 

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. 

360 

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 ) 

390 

391 

392_STATIC_MODES: Final = frozenset( 

393 (MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic, MCPAuth.token, MCPAuth.authorization) 

394) 

395 

396 

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 

423 

424 

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

448 

449 

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