diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 4aa08020527..cf74cbd187e 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -1600,7 +1600,7 @@ async def refresh_user_oauth_token( ) -> OAuthCredentialPayload | None: """Attempt to refresh a per-user OAuth2 token using its stored refresh_token. - POSTs to ``server.token_url`` with ``grant_type=refresh_token``. + POSTs to ``server.effective_token_url`` with ``grant_type=refresh_token``. On success: persists the new credential via ``store_user_oauth_credential`` and returns the updated payload dict. @@ -1609,7 +1609,7 @@ async def refresh_user_oauth_token( stale credential and triggering re-authentication. """ refresh_token: Final[str | None] = cred.get("refresh_token") - token_url: Final[str | None] = getattr(server, "token_url", None) + token_url: Final[str | None] = getattr(server, "effective_token_url", None) or getattr(server, "token_url", None) server_id: Final[str] = getattr(server, "server_id", "") client_id: Final[str | None] = getattr(server, "client_id", None) client_secret: Final[str | None] = getattr(server, "client_secret", None) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index aef4f5dc721..93b85edd88d 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -3,7 +3,7 @@ import html as _html import json import secrets import time -from collections.abc import Mapping +from collections.abc import Callable, Mapping from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Final, Literal, Optional from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse @@ -663,6 +663,26 @@ def _endpoint_not_configured_detail( ) +async def _server_with_oauth_endpoints( + mcp_server: MCPServer, + needed_endpoint: Callable[[MCPServer], str | None], +) -> MCPServer: + """Join deferred OAuth discovery only when the endpoint this caller needs is still missing. + + Admin-entered endpoints live on ``configured_*`` after an anchored issuer empties the + resolved fields. A caller whose needed endpoint already resolves never awaits discovery + and cannot 503 over a leftover pin. A server still missing it joins the deferred task; + no slot is a no-op and the caller 400s. + """ + if needed_endpoint(mcp_server) is not None: + return mcp_server + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load + global_mcp_server_manager, + ) + + return await global_mcp_server_manager.ensure_oauth_metadata_discovered(mcp_server) + + def _raise_unless_oauth2_discovery_server( mcp_server: MCPServer | None, mcp_server_name: str | None, @@ -697,7 +717,7 @@ def _dcr_bridge_relays_client_registration(mcp_server: MCPServer) -> bool: returns directly to the client's redirect URI without transiting the gateway. Gateway-side redirect trust and the ``/callback`` state relay therefore only apply to the short-circuit arm, where the upstream only knows the gateway's own callback.""" - return mcp_server.is_dcr_bridge and bool(mcp_server.registration_url) and not mcp_server.client_id + return mcp_server.is_dcr_bridge and bool(mcp_server.effective_registration_url) and not mcp_server.client_id def _require_s256_pkce( @@ -745,7 +765,7 @@ def _redirect_to_upstream_authorize( **({"scope": scope_value} if scope_value else {}), **({"resource": upstream_resource} if upstream_resource else {}), } - parsed_auth_url: Final = urlparse(mcp_server.authorization_url or "") + parsed_auth_url: Final = urlparse(mcp_server.effective_authorization_url or "") merged_params: Final = {**dict(parse_qsl(parsed_auth_url.query)), **passthrough_params} return RedirectResponse(urlunparse(parsed_auth_url._replace(query=urlencode(merged_params)))) @@ -812,18 +832,19 @@ async def authorize_with_server( ephemeral_dcr_client: "EphemeralDcrClient | None" = None, ): _raise_if_not_oauth2(mcp_server) - if mcp_server.authorization_url is None: + resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _register_flow_needed_endpoint) + if resolved_server.effective_authorization_url is None: raise HTTPException( status_code=400, detail=_endpoint_not_configured_detail( - mcp_server, + resolved_server, "authorization url", "set Authorization URL and Token URL manually", "set Issuer to discover them from the identity provider (RFC 8414)", ), ) - if mcp_server.is_dcr_bridge: + if resolved_server.is_dcr_bridge: # Enforce S256 PKCE on both bridge arms. The relay arm forwards the validated, # now-non-optional pair to the upstream authorize; the short-circuit arm keeps # calling this for its enforcement side effect, then falls through to the gateway @@ -832,9 +853,9 @@ async def authorize_with_server( # A gateway-minted ephemeral client is registered against {base}/callback, so its # flow must run the short-circuit arm; the relay arm is only for clients that # registered themselves through the front door and hold their own redirect binding. - if _dcr_bridge_relays_client_registration(mcp_server) and ephemeral_dcr_client is None: + if _dcr_bridge_relays_client_registration(resolved_server) and ephemeral_dcr_client is None: return _redirect_to_upstream_authorize( - mcp_server=mcp_server, + mcp_server=resolved_server, client_id=client_id, redirect_uri=redirect_uri, state=state, @@ -860,7 +881,7 @@ async def authorize_with_server( # litellm key, so the browser session is the only identity source; without one there is nothing to # bind, so send the user through login first. Every other oauth2 server keeps the identity-less state. litellm_user_id: str | None = None - if mcp_server.is_dcr_bridge and mcp_server.is_oauth_delegate: + if resolved_server.is_dcr_bridge and resolved_server.is_oauth_delegate: from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # inline import avoids a module-load circular import _user_id_from_session_cookie, ) @@ -870,7 +891,7 @@ async def authorize_with_server( return _redirect_to_litellm_login(request) denial: Final = await _bridge_authorize_access_denial( litellm_user_id=litellm_user_id, - mcp_server=mcp_server, + mcp_server=resolved_server, redirect_uri=redirect_uri, state=state, ) @@ -884,7 +905,7 @@ async def authorize_with_server( code_challenge_method=code_challenge_method, client_redirect_uri=redirect_uri, litellm_user_id=litellm_user_id, - mcp_server_id=mcp_server.server_id if (litellm_user_id or ephemeral_dcr_client) else None, + mcp_server_id=resolved_server.server_id if (litellm_user_id or ephemeral_dcr_client) else None, dcr_client_id=ephemeral_dcr_client.client_id if ephemeral_dcr_client else None, dcr_client_secret=ephemeral_dcr_client.client_secret if ephemeral_dcr_client else None, dcr_token_endpoint_auth_method=ephemeral_dcr_client.token_endpoint_auth_method @@ -894,26 +915,26 @@ async def authorize_with_server( relay_state: Final = secrets.token_urlsafe(_OAUTH_STATE_HANDLE_BYTES) params: Final = { - "client_id": mcp_server.client_id if mcp_server.client_id else client_id, + "client_id": resolved_server.client_id if resolved_server.client_id else client_id, "redirect_uri": f"{request_base_url}/callback", "state": relay_state, "response_type": response_type or "code", } if scope: params["scope"] = scope - elif mcp_server.scopes: - params["scope"] = " ".join(mcp_server.scopes) + elif resolved_server.scopes: + params["scope"] = " ".join(resolved_server.scopes) if code_challenge: params["code_challenge"] = code_challenge if code_challenge_method: params["code_challenge_method"] = code_challenge_method - upstream_resource: Final = resolve_upstream_resource(mcp_server) + upstream_resource: Final = resolve_upstream_resource(resolved_server) if upstream_resource: params["resource"] = upstream_resource - parsed_auth_url: Final = urlparse(mcp_server.authorization_url) + parsed_auth_url: Final = urlparse(resolved_server.effective_authorization_url) existing_params: Final = dict(parse_qsl(parsed_auth_url.query)) existing_params.update(params) final_url: Final = urlunparse(parsed_auth_url._replace(query=urlencode(existing_params))) @@ -946,11 +967,13 @@ async def exchange_token_with_server( if grant_type not in ("authorization_code", "refresh_token"): raise HTTPException(status_code=400, detail="Unsupported grant_type") - if mcp_server.token_url is None: + resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _token_flow_needed_endpoint) + token_url: Final = resolved_server.effective_token_url + if token_url is None: raise HTTPException( status_code=400, detail=_endpoint_not_configured_detail( - mcp_server, + resolved_server, "token url", "set Token URL manually", "set Issuer to discover it from the identity provider (RFC 8414)", @@ -965,16 +988,16 @@ async def exchange_token_with_server( # recovered from a sealed code) must authenticate the way its own registration was granted, # not the way the server row is configured; callers that carry no method keep the row's method # as before. - resolved_client_id: Final = mcp_server.client_id if mcp_server.client_id else client_id - resolved_client_secret: Final = mcp_server.client_secret if mcp_server.client_id else client_secret + resolved_client_id: Final = resolved_server.client_id if resolved_server.client_id else client_id + resolved_client_secret: Final = resolved_server.client_secret if resolved_server.client_id else client_secret resolved_auth_method: Final = ( - mcp_server.token_endpoint_auth_method - if mcp_server.client_id - else (client_token_endpoint_auth_method or mcp_server.token_endpoint_auth_method) + resolved_server.token_endpoint_auth_method + if resolved_server.client_id + else (client_token_endpoint_auth_method or resolved_server.token_endpoint_auth_method) ) try: token_request: Final = build_upstream_oauth2_token_request( - mcp_server, + resolved_server, auth_method=resolved_auth_method, client_id=resolved_client_id, client_secret=resolved_client_secret, @@ -987,14 +1010,14 @@ async def exchange_token_with_server( bridge_upstream_refresh: SecretStr | None = None bridge_upstream_scope: str | None = None refresh_request_scope: str | None = None - is_bridge: Final = mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge + is_bridge: Final = resolved_server.is_oauth_delegate and resolved_server.is_dcr_bridge if grant_type == "refresh_token": # Phase 1 for a bridge refresh: open the client's refresh envelope, re-validate the sealed # identity, and unwrap the real upstream refresh token BEFORE building token_data, so the exchange # sends the upstream token and never the envelope. A failure returns without touching the upstream. if is_bridge: - prepared_refresh: Final = await _prepare_bridge_refresh(mcp_server, refresh_token) + prepared_refresh: Final = await _prepare_bridge_refresh(resolved_server, refresh_token) if not isinstance(prepared_refresh, _BridgeRefreshReady): return _bridge_mint_error_response(prepared_refresh) bridge_mint_ready = prepared_refresh.ready @@ -1031,13 +1054,13 @@ async def exchange_token_with_server( # A raw upstream code (scripted path) opens to None and the code is used as-is. bridge_identity = open_bridge_authorization_code(code) if bridge_identity is not None: - if bridge_identity.mcp_server_id != mcp_server.server_id: + if bridge_identity.mcp_server_id != resolved_server.server_id: raise HTTPException( status_code=400, detail="Authorization code was issued for a different MCP server", ) code = bridge_identity.upstream_code - bridge_token_relay: Final = _dcr_bridge_relays_client_registration(mcp_server) + bridge_token_relay: Final = _dcr_bridge_relays_client_registration(resolved_server) if bridge_token_relay and not redirect_uri: raise HTTPException( status_code=400, @@ -1059,7 +1082,7 @@ async def exchange_token_with_server( # Phase 1 for a bridge authorization_code mint: resolve identity (the SSO user recovered above, or # the presented litellm key) and the envelope keys BEFORE the exchange consumes the single-use code. if is_bridge: - prepared: Final = await _prepare_bridge_mint(request, mcp_server, bridge_identity) + prepared: Final = await _prepare_bridge_mint(request, resolved_server, bridge_identity) if not isinstance(prepared, _BridgeMintReady): return _bridge_mint_error_response(prepared) bridge_mint_ready = prepared @@ -1067,7 +1090,7 @@ async def exchange_token_with_server( async_client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) try: response: Final = await async_client.post( - mcp_server.token_url, + token_url, headers={"Accept": "application/json", **token_request.headers}, data=token_data, ) @@ -1076,8 +1099,8 @@ async def exchange_token_with_server( except httpx.HTTPStatusError as exc: fault: Final = classify_upstream_token_rejection( exc.response, - credential_source=_token_credential_source(mcp_server), - log_context=mcp_server.server_id, + credential_source=_token_credential_source(resolved_server), + log_context=resolved_server.server_id, ) upstream_rejected_bridge_refresh: Final = ( is_bridge @@ -1090,7 +1113,7 @@ async def exchange_token_with_server( "bridge refresh: the upstream rejected the sealed refresh token for server=%s with " "invalid_grant (revoked or expired at the IdP); returning invalid_grant so the client " "re-runs authorization_code rather than an opaque upstream error", - mcp_server.server_id, + resolved_server.server_id, ) return _bridge_mint_error_response("invalid_refresh") return render_token_fault(fault) @@ -1103,22 +1126,22 @@ async def exchange_token_with_server( # Validate token response against server-configured rules before any storage. # This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc. - if mcp_server.token_validation and isinstance(mcp_server.token_validation, dict): + if resolved_server.token_validation and isinstance(resolved_server.token_validation, dict): _validate_token_response( token_response=token_response, - validation_rules=mcp_server.token_validation, - server_id=mcp_server.server_id, + validation_rules=resolved_server.token_validation, + server_id=resolved_server.server_id, ) # Store server-side when the server is configured for per-user OAuth and # the calling client has provided a valid LiteLLM identity. # Errors are non-fatal: the token is still returned to the client. - if mcp_server.needs_user_oauth_token: + if resolved_server.needs_user_oauth_token: user_id: Final = await _extract_user_id_from_request(request) if user_id: try: await _store_per_user_token_server_side( - server=mcp_server, + server=resolved_server, user_id=user_id, token_response=token_response, ) @@ -1126,7 +1149,7 @@ async def exchange_token_with_server( verbose_logger.warning( "exchange_token_with_server: server-side storage failed for user=%s server=%s: %s", user_id, - mcp_server.server_id, + resolved_server.server_id, exc, ) else: @@ -1136,7 +1159,7 @@ async def exchange_token_with_server( "requires the stored token, so the client will be challenged with 401 on reconnect. " "Ensure the request carries a valid LiteLLM key (x-litellm-api-key or Authorization), " "or store it via POST /mcp/server/{id}/oauth-user-credential.", - mcp_server.server_id, + resolved_server.server_id, ) # A DCR-bridge oauth_delegate server hands the client a gateway-bound envelope (identity plus the @@ -1147,7 +1170,9 @@ async def exchange_token_with_server( token_response = {**token_response, "scope": refresh_request_scope} # Phase 3: seal the upstream grant into the client-held envelope; failures map through the same # OAuth-shaped response as the phase-1 preconditions. - minted: Final = _finish_bridge_mint(bridge_mint_ready, mcp_server, token_response, datetime.now(timezone.utc)) + minted: Final = _finish_bridge_mint( + bridge_mint_ready, resolved_server, token_response, datetime.now(timezone.utc) + ) return minted if isinstance(minted, JSONResponse) else _bridge_mint_error_response(minted) raw_access_token: Final = token_response.get("access_token") if isinstance(token_response, dict) else None @@ -1551,7 +1576,8 @@ async def mint_ephemeral_dcr_client(request: Request, mcp_server: MCPServer) -> bounded by the server count even when the request origin varies) so parallel authorize requests cannot each register an upstream client; the cache stamps nothing onto the server record and correctness never depends on it because the sealed state carries the client through the flow.""" - if mcp_server.registration_url is None: + registration_url: Final = mcp_server.effective_registration_url + if registration_url is None: return None request_base_url: Final = get_request_base_url(request) cache_key: Final = f"mcp_ephemeral_dcr_client:{mcp_server.server_id}:{request_base_url}" @@ -1571,7 +1597,7 @@ async def mint_ephemeral_dcr_client(request: Request, mcp_server: MCPServer) -> "token_endpoint_auth_method": "none", } response: Final = await _post_dcr_registration( - registration_url=mcp_server.registration_url, + registration_url=registration_url, register_data=register_data, server_id=mcp_server.server_id, ) @@ -1617,7 +1643,7 @@ async def resolve_ephemeral_dcr_client( usable to generate orphan IdP clients).""" if not (mcp_server.is_true_passthrough or (mcp_server.is_oauth_delegate and not mcp_server.is_dcr_bridge)): return None - if mcp_server.authorization_url is None: + if mcp_server.effective_authorization_url is None: raise HTTPException( status_code=400, detail="MCP server authorization url is not set", @@ -1627,6 +1653,29 @@ async def resolve_ephemeral_dcr_client( return await mint_ephemeral_dcr_client(request, mcp_server) +def _register_flow_needed_endpoint(mcp_server: MCPServer) -> str | None: + """The register flow's deferred-discovery join gate. A DCR bridge with no admin-configured + client can only register callers through the upstream's registration endpoint + (``_oauth_endpoints_unresolved`` keeps its discovery slot armed for exactly this shape), so + the flow must keep joining discovery while registration is still missing instead of silently + degrading to the dummy short-circuit. Every other shape only needs the authorization url.""" + if mcp_server.is_dcr_bridge and not mcp_server.client_id and mcp_server.effective_registration_url is None: + return None + return mcp_server.effective_authorization_url + + +def _token_flow_needed_endpoint(mcp_server: MCPServer) -> str | None: + """The token exchange's deferred-discovery join gate. The exchange's relay-vs-callback arm + (:func:`_dcr_bridge_relays_client_registration`) reads the registration url, so a clientless + DCR bridge rebuilt without its discovered registration endpoint must keep joining discovery + even when the token url already resolves; skipping it would select the gateway-callback arm + and the upstream would reject the code over a redirect_uri mismatch. Every other shape only + needs the token url.""" + if mcp_server.is_dcr_bridge and not mcp_server.client_id and mcp_server.effective_registration_url is None: + return None + return mcp_server.effective_token_url + + async def register_client_with_server( request: Request, mcp_server: MCPServer, @@ -1661,21 +1710,23 @@ async def register_client_with_server( ): return dummy_return - if mcp_server.authorization_url is None: + resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _register_flow_needed_endpoint) + if resolved_server.effective_authorization_url is None: raise HTTPException( status_code=400, detail=_endpoint_not_configured_detail( - mcp_server, + resolved_server, "authorization url", "set Authorization URL and Token URL manually", "set Issuer to discover them from the identity provider (RFC 8414)", ), ) - if mcp_server.registration_url is None: + registration_url: Final = resolved_server.effective_registration_url + if registration_url is None: return dummy_return - bridge_relay: Final = _dcr_bridge_relays_client_registration(mcp_server) + bridge_relay: Final = _dcr_bridge_relays_client_registration(resolved_server) if bridge_relay and not client_redirect_uris: raise HTTPException( status_code=400, @@ -1690,15 +1741,17 @@ async def register_client_with_server( "token_endpoint_auth_method": token_endpoint_auth_method or ("none" if bridge_relay else ""), } response: Final = await _post_dcr_registration( - registration_url=mcp_server.registration_url, + registration_url=registration_url, register_data=register_data, - server_id=mcp_server.server_id, + server_id=resolved_server.server_id, ) token_response = response.json() if persist_credentials and not bridge_relay: - persistence_result = await _persist_dcr_client_registration(mcp_server, token_response, current_redirect_uri) + persistence_result = await _persist_dcr_client_registration( + resolved_server, token_response, current_redirect_uri + ) if persistence_result == "reused": return dummy_return @@ -1755,17 +1808,10 @@ async def authorize( lookup_name: Final[str | None] = mcp_server_name or client_id client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) mcp_server = ( - await global_mcp_server_manager.get_resolved_mcp_server_by_name(lookup_name, client_ip=client_ip) - if lookup_name - else None + global_mcp_server_manager.get_mcp_server_by_name(lookup_name, client_ip=client_ip) if lookup_name else None ) if mcp_server is None and mcp_server_name is None: - unresolved_server: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) - mcp_server = ( - await global_mcp_server_manager.ensure_oauth_metadata_discovered(unresolved_server) - if unresolved_server is not None - else None - ) + mcp_server = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) if mcp_server is None: raise HTTPException(status_code=404, detail="MCP server not found") _raise_if_not_oauth2(mcp_server) @@ -1846,14 +1892,9 @@ async def token_endpoint( lookup_name: Final = mcp_server_name or client_id client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) - mcp_server = await global_mcp_server_manager.get_resolved_mcp_server_by_name(lookup_name, client_ip=client_ip) + mcp_server = global_mcp_server_manager.get_mcp_server_by_name(lookup_name, client_ip=client_ip) if mcp_server is None and mcp_server_name is None: - unresolved_server: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) - mcp_server = ( - await global_mcp_server_manager.ensure_oauth_metadata_discovered(unresolved_server) - if unresolved_server is not None - else None - ) + mcp_server = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) if mcp_server is None: raise HTTPException(status_code=404, detail="MCP server not found") return await exchange_token_with_server( @@ -2684,10 +2725,9 @@ async def register_client(request: Request, mcp_server_name: str | None = None): return await register_aggregate_client(request=request, request_body=data) resolved: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) if resolved: - resolved_server: Final = await global_mcp_server_manager.ensure_oauth_metadata_discovered(resolved) return await register_client_with_server( request=request, - mcp_server=resolved_server, + mcp_server=resolved, client_name=data.get("client_name", ""), grant_types=data.get("grant_types", []), response_types=data.get("response_types", []), @@ -2697,10 +2737,7 @@ async def register_client(request: Request, mcp_server_name: str | None = None): ) return dummy_return - mcp_server: Final = await global_mcp_server_manager.get_resolved_mcp_server_by_name( - mcp_server_name, - client_ip=client_ip, - ) + mcp_server: Final = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name, client_ip=client_ip) if mcp_server is None: return dummy_return return await register_client_with_server( diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 7ab26db0f3e..308813039ca 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -523,7 +523,7 @@ def _oauth_endpoints_unresolved(server: MCPServer) -> bool: # can come from resource discovery, so a server that resolved its endpoints but no scopes is # still unresolved for its flow. return True - if server.is_dcr_bridge and not server.client_id and server.registration_url is None: + if server.is_dcr_bridge and not server.client_id and server.effective_registration_url is None: # A DCR bridge with no admin-configured client can only register callers through the # upstream's registration endpoint, so a build that resolved the authorize and token # endpoints but not registration_endpoint (partial metadata) is still unresolved for its @@ -535,8 +535,8 @@ def _oauth_endpoints_unresolved(server: MCPServer) -> bool: return _flow_endpoints_missing( server.auth_type, MCPServerManager.effective_oauth2_flow(server), - server.authorization_url, - server.token_url, + server.effective_authorization_url, + server.effective_token_url, server.token_exchange_endpoint, ) @@ -6205,14 +6205,6 @@ class MCPServerManager: return server return None - async def get_resolved_mcp_server_by_name( - self, - server_name: str, - client_ip: str | None = None, - ) -> MCPServer | None: - server: Final = self.get_mcp_server_by_name(server_name, client_ip=client_ip) - return await self.ensure_oauth_metadata_discovered(server) if server is not None else None - def get_filtered_registry(self, client_ip: str | None = None) -> dict[str, MCPServer]: """ Get registry filtered by client IP access control. diff --git a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py index c76c933c5b5..b3f1da51074 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py +++ b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py @@ -67,7 +67,7 @@ class MCPOAuth2TokenCache(InMemoryCache): rest of the identity rather than stored in a key.""" material: Final = "\x00".join( ( - server.token_url or "", + server.effective_token_url or "", server.client_id or "", server.client_secret or "", " ".join(server.scopes or ()), @@ -82,7 +82,7 @@ class MCPOAuth2TokenCache(InMemoryCache): @staticmethod def _has_client_credentials_config(server: "MCPServer") -> bool: - return bool(server.client_id and server.client_secret and server.token_url) + return bool(server.client_id and server.client_secret and server.effective_token_url) async def async_get_token(self, server: "MCPServer") -> str | None: """Return a valid access token, fetching or refreshing as needed. @@ -112,19 +112,20 @@ class MCPOAuth2TokenCache(InMemoryCache): return token async def _fetch_token(self, server: "MCPServer") -> tuple[str, int]: - """POST to ``token_url`` with ``grant_type=client_credentials``. + """POST to ``effective_token_url`` with ``grant_type=client_credentials``. Returns ``(access_token, ttl_seconds)`` where ttl accounts for the expiry buffer so the cache entry expires before the real token does. """ client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) - if not server.client_id or not server.client_secret or not server.token_url: + token_url: Final = server.effective_token_url + if not server.client_id or not server.client_secret or not token_url: raise ValueError( f"MCP server '{server.server_id}' missing required OAuth2 fields: " f"client_id={bool(server.client_id)}, " f"client_secret={bool(server.client_secret)}, " - f"token_url={bool(server.token_url)}" + f"token_url={bool(token_url)}" ) token_request: Final = build_upstream_oauth2_token_request( @@ -146,7 +147,7 @@ class MCPOAuth2TokenCache(InMemoryCache): ) try: - response: Final = await client.post(server.token_url, data=data, headers=token_request.headers or None) + response: Final = await client.post(token_url, data=data, headers=token_request.headers or None) response.raise_for_status() except httpx.HTTPStatusError as exc: raise ValueError( diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index be8ec1b8eb3..98e239b1d1d 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -142,7 +142,7 @@ def _client_credentials_spec(server: MCPServer, resource: str) -> ServerSpec: config=ClientCredentialsConfig( client_id=server.client_id, client_secret=SecretStr(server.client_secret) if server.client_secret else None, - token_url=server.token_url, + token_url=server.effective_token_url, scopes=tuple(server.scopes or ()), audience=server.audience, upstream_resource=resolve_upstream_resource(server), @@ -163,7 +163,7 @@ def _token_exchange_spec(server: MCPServer, resource: str) -> ServerSpec | None: normalizes to ``rfc8693`` so a bad config value cannot crash spec-building. ``audience`` is forwarded only when the operator set it; a missing one is omitted, not derived. """ - endpoint: Final = server.token_exchange_endpoint or server.token_url + endpoint: Final = server.token_exchange_endpoint or server.effective_token_url if not server.client_id or not server.client_secret: return None profile: Final[Literal["rfc8693", "entra_obo"]] = ( diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/authz_code_refresher.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/authz_code_refresher.py index 6ea5756d43d..92bd30694af 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/authz_code_refresher.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/authz_code_refresher.py @@ -88,7 +88,10 @@ class AuthorizationCodeRefresher: if token.refresh_token is None: return None server: Final = self._server_lookup(server_id) - if server is None or not server.token_url: + if server is None: + return None + token_url: Final = server.effective_token_url + if not token_url: return None try: @@ -106,7 +109,7 @@ class AuthorizationCodeRefresher: "refresh_token": token.refresh_token, **token_request.body, } - body: Final = await self._token_endpoint(server.token_url, form, token_request.headers) + body: Final = await self._token_endpoint(token_url, form, token_request.headers) if body is None: return None access_token: Final = body.get("access_token") diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index d09503cdc4d..401793a79e4 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -183,6 +183,18 @@ class MCPServer(BaseModel): def __str__(self) -> str: return self.__repr__() + @property + def effective_authorization_url(self) -> str | None: + return self.authorization_url or self.configured_authorization_url + + @property + def effective_token_url(self) -> str | None: + return self.token_url or self.configured_token_url + + @property + def effective_registration_url(self) -> str | None: + return self.registration_url or self.configured_registration_url + @property def has_client_credentials(self) -> bool: """True if this server should use the OAuth2 client_credentials (M2M) flow. diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index e336bdc80c2..0020dbf8d61 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -579,3 +579,22 @@ def test_id_jag_honors_explicit_subject_token_type(): def test_id_jag_half_configured_defers_to_v1(server): # A half-configured server must defer (None) rather than 500 at IdJagConfig construction. assert to_server_spec(server) is None + + +def test_client_credentials_uses_admin_entered_token_url_when_issuer_yield_empties_resolved(): + """A pinned issuer empties the resolved token_url while configured_token_url keeps the + admin-entered value; the M2M spec must carry it so egress can mint.""" + spec = to_server_spec( + _server( + auth_type=MCPAuth.oauth2, + oauth2_flow="client_credentials", + url="https://up.example.com/mcp", + token_url=None, + configured_token_url="https://idp.example.com/token", + client_id="cid", + client_secret="csec", + ) + ) + assert spec is not None + assert isinstance(spec.config, ClientCredentialsConfig) + assert spec.config.token_url == "https://idp.example.com/token" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_authz_code_refresher.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_authz_code_refresher.py index ab414d1e8a4..bb2f2ff8b02 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_authz_code_refresher.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_authz_code_refresher.py @@ -20,8 +20,10 @@ class _Server: upstream_resource=None, url=None, server_id="srv", + configured_token_url=None, ): self.token_url = token_url + self.configured_token_url = configured_token_url self.client_id = client_id self.client_secret = client_secret self.token_endpoint_auth_method = token_endpoint_auth_method @@ -29,6 +31,10 @@ class _Server: self.url = url self.server_id = server_id + @property + def effective_token_url(self): + return self.token_url or self.configured_token_url + def _lookup(server): return lambda server_id: server @@ -262,3 +268,22 @@ async def test_returned_scope_overrides_prior_when_present(): assert token is not None assert token.scopes == ("read",) # a present scope replaces the prior grant assert persisted[0][5] == ("read",) + + +@pytest.mark.asyncio +async def test_refresh_uses_admin_entered_token_url_when_issuer_yield_empties_resolved(): + """A pinned issuer empties the resolved token_url while configured_token_url keeps the + admin-entered value; the refresh grant must POST there instead of silently failing.""" + posted = [] + refresher = _refresher( + server=_Server(token_url=None, configured_token_url="https://idp.example.com/token"), + body={"access_token": "new-at", "expires_in": 3600}, + post_sink=posted, + ) + token = await refresher.refresh( + "alice", "srv", OAuthToken(access_token="old", refresh_token="old-rt") + ) + + assert token is not None + assert token.access_token == "new-at" + assert posted[0][0] == "https://idp.example.com/token" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py index 50248e95ffa..4d9142ad4c5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py @@ -1337,3 +1337,28 @@ def test_mcp_oauth_token_identity_changes_when_only_upstream_resource_is_edited( assert mcp_oauth_token_identity(set_to_explicit) == mcp_oauth_token_identity( _identity_server(credentials={**creds, "upstream_resource": "api://audience-one"}) ) + + +@pytest.mark.asyncio +async def test_refresh_user_oauth_token_uses_admin_entered_token_url_when_issuer_yield_empties_resolved(monkeypatch): + """A pinned issuer empties the resolved token_url while configured_token_url keeps the + admin-entered value; the silent per-user refresh must POST there instead of bailing.""" + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="srv-1", + name="test", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="cid", + client_secret="csec", + token_url=None, + configured_token_url="https://idp.example.com/token", + ) + result, captured = await _run_refresh(monkeypatch, server) + + assert result is not None + assert captured["url"] == "https://idp.example.com/token" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 828d2785d0b..0c809940b84 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -79,24 +79,23 @@ def _resolved_oauth_metadata(): @pytest.mark.asyncio -async def test_authorize_resolves_cold_oauth_metadata(): +async def test_authorize_resolves_cold_oauth_metadata(monkeypatch): + """The route hands the registered server to the flow, whose deferred-discovery join resolves + the cold metadata; the redirect must land on the discovered authorization endpoint.""" from litellm.proxy._experimental.mcp_server import discoverable_endpoints from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit-6255") server = _unresolved_oauth_server() global_mcp_server_manager.registry[server.server_id] = server global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True) request = _mock_callback_request("https://litellm.example.com/") - expected = MagicMock() - with ( - patch.object( - global_mcp_server_manager, - "_discover_oauth_metadata_for_server", - new=AsyncMock(return_value=_resolved_oauth_metadata()), - ) as discovery, - patch.object(discoverable_endpoints, "authorize_with_server", new=AsyncMock(return_value=expected)) as relay, - ): + with patch.object( + global_mcp_server_manager, + "_discover_oauth_metadata_for_server", + new=AsyncMock(return_value=_resolved_oauth_metadata()), + ) as discovery: response = await discoverable_endpoints.authorize( request=request, client_id="client-id", @@ -105,12 +104,14 @@ async def test_authorize_resolves_cold_oauth_metadata(): ) discovery.assert_awaited_once_with(server) - assert relay.await_args.kwargs["mcp_server"].authorization_url == "https://idp.example.com/authorize" - assert response is expected + assert response.status_code == 307 + assert response.headers["location"].startswith("https://idp.example.com/authorize") @pytest.mark.asyncio async def test_token_resolves_cold_oauth_metadata(): + """The route hands the registered server to the exchange, whose deferred-discovery join + resolves the cold metadata; the exchange must post to the discovered token endpoint.""" from litellm.proxy._experimental.mcp_server import discoverable_endpoints from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager @@ -118,7 +119,11 @@ async def test_token_resolves_cold_oauth_metadata(): global_mcp_server_manager.registry[server.server_id] = server global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True) request = _mock_callback_request("https://litellm.example.com/") - expected = MagicMock() + fake_http_response = MagicMock() + fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) with ( patch.object( @@ -127,8 +132,10 @@ async def test_token_resolves_cold_oauth_metadata(): new=AsyncMock(return_value=_resolved_oauth_metadata()), ) as discovery, patch.object( - discoverable_endpoints, "exchange_token_with_server", new=AsyncMock(return_value=expected) - ) as relay, + discoverable_endpoints, + "get_async_httpx_client", + new=lambda llm_provider: fake_http_client, + ), ): response = await discoverable_endpoints.token_endpoint( request=request, @@ -139,20 +146,26 @@ async def test_token_resolves_cold_oauth_metadata(): ) discovery.assert_awaited_once_with(server) - assert relay.await_args.kwargs["mcp_server"].token_url == "https://idp.example.com/token" - assert response is expected + assert response.status_code == 200 + assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/token" @pytest.mark.asyncio async def test_register_resolves_cold_oauth_metadata(): + """The route hands the registered server to the registration flow, whose deferred-discovery + join resolves the cold metadata; DCR must post to the discovered registration endpoint.""" from litellm.proxy._experimental.mcp_server import discoverable_endpoints from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager - server = _unresolved_oauth_server() + server = _unresolved_oauth_server().model_copy(update={"client_id": None}) global_mcp_server_manager.registry[server.server_id] = server global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True) request = _mock_callback_request("https://litellm.example.com/") - expected = MagicMock() + fake_http_response = MagicMock() + fake_http_response.json.return_value = {"client_id": "generated-client", "client_secret": "generated-secret"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) with ( patch.object( @@ -162,14 +175,135 @@ async def test_register_resolves_cold_oauth_metadata(): ) as discovery, patch.object(discoverable_endpoints, "_read_request_body", new=AsyncMock(return_value={})), patch.object( - discoverable_endpoints, "register_client_with_server", new=AsyncMock(return_value=expected) - ) as relay, + discoverable_endpoints, + "get_async_httpx_client", + new=lambda llm_provider: fake_http_client, + ), ): response = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_name) discovery.assert_awaited_once_with(server) - assert relay.await_args.kwargs["mcp_server"].registration_url == "https://idp.example.com/register" - assert response is expected + assert response.status_code == 200 + assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/register" + + +@pytest.mark.asyncio +async def test_register_route_bridge_missing_registration_url_joins_discovery(): + """A clientless DCR bridge whose authorize and token urls are admin-entered still relays + registration upstream: the flow must join deferred discovery for the missing registration + endpoint instead of short-circuiting to dummy credentials because authorization resolves.""" + import json + + from litellm.proxy._experimental.mcp_server import discoverable_endpoints + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="bridge-partial-metadata", + name="bridge_partial_metadata", + server_name="bridge_partial_metadata", + alias="bridge_partial_metadata", + url="https://mcp.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + dcr_bridge=True, + client_id=None, + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + ) + global_mcp_server_manager.registry[server.server_id] = server + global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True) + request = _mock_callback_request("https://litellm.example.com/") + fake_http_response = MagicMock() + fake_http_response.json.return_value = {"client_id": "generated-client", "client_secret": "generated-secret"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + + with ( + patch.object( # test-quality-ok: innermost discovery seam on a module-global manager; the route-to-flow join under test stays real + global_mcp_server_manager, + "_discover_oauth_metadata_for_server", + new=AsyncMock(return_value=_resolved_oauth_metadata()), + ) as discovery, + patch.object( # test-quality-ok: the MagicMock Request carries no body; this seam feeds the RFC 7591 redirect_uris + discoverable_endpoints, + "_read_request_body", + new=AsyncMock(return_value={"redirect_uris": ["https://client.example.com/cb"]}), + ), + patch.object( # test-quality-ok: keeps the DCR POST off the network so its target URL can be asserted + discoverable_endpoints, + "get_async_httpx_client", + new=lambda llm_provider: fake_http_client, + ), + ): + response = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_name) + + discovery.assert_awaited_once_with(server) + assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/register" + assert fake_http_client.post.await_args.kwargs["json"]["redirect_uris"] == ["https://client.example.com/cb"] + assert response.status_code == 200 + assert json.loads(response.body.decode("utf-8"))["client_id"] == "generated-client" + + +@pytest.mark.asyncio +async def test_token_route_bridge_missing_registration_url_joins_discovery(): + """A clientless DCR bridge rebuilt with an admin-entered token url but without its discovered + registration endpoint must rejoin discovery at the exchange: the relay-vs-callback arm hinges + on the registration url, so skipping discovery would swap the client's own redirect_uri for + the gateway callback and the upstream would reject the code.""" + from litellm.proxy._experimental.mcp_server import discoverable_endpoints + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="bridge-partial-token-metadata", + name="bridge_partial_token_metadata", + server_name="bridge_partial_token_metadata", + alias="bridge_partial_token_metadata", + url="https://mcp.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + dcr_bridge=True, + client_id=None, + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + ) + global_mcp_server_manager.registry[server.server_id] = server + global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True) + request = _mock_callback_request("https://litellm.example.com/") + fake_http_response = MagicMock() + fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + + with ( + patch.object( # test-quality-ok: innermost discovery seam on a module-global manager; the route-to-exchange join under test stays real + global_mcp_server_manager, + "_discover_oauth_metadata_for_server", + new=AsyncMock(return_value=_resolved_oauth_metadata()), + ) as discovery, + patch.object( # test-quality-ok: keeps the upstream token POST off the network so its redirect_uri arm can be asserted + discoverable_endpoints, + "get_async_httpx_client", + new=lambda llm_provider: fake_http_client, + ), + ): + response = await discoverable_endpoints.token_endpoint( + request=request, + grant_type="authorization_code", + code="upstream-code", + redirect_uri="https://client.example.com/cb", + client_id="dcr-client-id", + mcp_server_name=server.server_name, + ) + + discovery.assert_awaited_once_with(server) + assert response.status_code == 200 + assert fake_http_client.post.await_args.kwargs["data"]["redirect_uri"] == "https://client.example.com/cb" @pytest.fixture @@ -8878,6 +9012,272 @@ async def test_authorize_wall_names_the_issuer_for_anchored_servers(): assert "idp.example.com" not in detail_text +@pytest.mark.asyncio +async def test_authorize_uses_admin_entered_github_oauth_urls_after_issuer_yield(monkeypatch): + """GitHub MCP servers store Authorization URL and Token URL on the row. 1.99 can empty + the resolved authorization_url when a leftover issuer is treated as a pin (RFC 8414 + yield). The UI authorize must still redirect to the admin-entered GitHub authorize URL + instead of 400ing that discovery against api.githubcopilot.com failed.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + authorize_with_server, + ) + from litellm.types.mcp import MCPAuth, MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="ecac50c4-8eca-438a-af80-9bdebadafc69", + name="github_mcp", + alias="github_mcp", + server_name="github_mcp", + url="https://api.githubcopilot.com/mcp/", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + client_id="github-app-client", + authorization_url=None, + token_url=None, + issuer="https://github.com", + issuer_is_anchored=True, + configured_authorization_url="https://github.com/login/oauth/authorize", + configured_token_url="https://github.com/login/oauth/access_token", + ) + mock_request = MagicMock() + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit-6255") + response = await authorize_with_server( + request=mock_request, + mcp_server=server, + client_id="github-app-client", + redirect_uri="http://127.0.0.1:60108/callback", + state="state123", + ) + + assert response.status_code == 307 + assert "https://github.com/login/oauth/authorize" in response.headers["location"] + assert "client_id=github-app-client" in response.headers["location"] + + +def test_oauth_endpoints_count_admin_entered_urls_as_resolved(): + """A leftover issuer empties the resolved authorize/token fields but must not keep the + server on the deferred-discovery retry path when the admin already stored those URLs.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _oauth_endpoints_unresolved, + ) + from litellm.types.mcp import MCPAuth, MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="github-configured", + name="github_mcp", + url="https://api.githubcopilot.com/mcp/", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + authorization_url=None, + token_url=None, + configured_authorization_url="https://github.com/login/oauth/authorize", + configured_token_url="https://github.com/login/oauth/access_token", + ) + assert _oauth_endpoints_unresolved(server) is False + + +@pytest.mark.asyncio +async def test_token_exchange_with_configured_token_url_never_joins_discovery(monkeypatch): + """A server can hold an admin-entered Token URL while its Authorization URL is absent. The + token exchange must post to that stored endpoint without awaiting deferred discovery, which + can 503 against an unreachable issuer even though nothing it resolves is needed here.""" + from litellm.proxy._experimental.mcp_server import ( + discoverable_endpoints, + mcp_server_manager, + ) + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + exchange_token_with_server, + ) + from litellm.types.mcp import MCPAuth, MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="token-url-only", + name="token_url_only", + server_name="token_url_only", + alias="token_url_only", + url="https://mcp.example.com/mcp/", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="cid", + client_secret="cs", + authorization_url=None, + token_url=None, + issuer="https://idp.example.com", + issuer_is_anchored=True, + configured_token_url="https://idp.example.com/oauth/token", + ) + + async def fail_discovery(_srv): + raise AssertionError("the exchange joined deferred discovery despite a stored token url") + + monkeypatch.setattr( + mcp_server_manager.global_mcp_server_manager, + "ensure_oauth_metadata_discovered", + fail_discovery, + ) + fake_http_response = MagicMock() + fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + monkeypatch.setattr( + discoverable_endpoints, + "get_async_httpx_client", + lambda llm_provider: fake_http_client, + ) + mock_request = MagicMock() + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + response = await exchange_token_with_server( + request=mock_request, + mcp_server=server, + grant_type="authorization_code", + code="upstream-code", + redirect_uri="http://127.0.0.1:3000/cb", + client_id="cid", + client_secret=None, + code_verifier=None, + ) + + assert response.status_code == 200 + assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/oauth/token" + + +@pytest.mark.asyncio +async def test_root_token_route_with_configured_token_url_never_joins_discovery(monkeypatch): + """A root POST /token that falls back to the sole OAuth2 server must reach the exchange's + endpoint-gated discovery join instead of awaiting full discovery at the route: with the + token url admin-entered, a failing or slow discovery must not turn the exchange into a 503.""" + from litellm.proxy._experimental.mcp_server import ( + discoverable_endpoints, + mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth, MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + manager = mcp_server_manager.global_mcp_server_manager + server = MCPServer( + server_id="sole-token-url-only", + name="sole_token_url_only", + server_name="sole_token_url_only", + alias="sole_token_url_only", + url="https://mcp.example.com/mcp/", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="cid", + client_secret="cs", + issuer="https://idp.example.com", + issuer_is_anchored=True, + configured_token_url="https://idp.example.com/oauth/token", + ) + saved_registry = dict(manager.registry) + manager.registry.clear() + manager.registry[server.server_id] = server + manager._set_oauth_discovery_deferred(server.server_id, True) + + async def fail_discovery(_srv): + raise AssertionError("the root token route joined deferred discovery despite a stored token url") + + monkeypatch.setattr(manager, "ensure_oauth_metadata_discovered", fail_discovery) + fake_http_response = MagicMock() + fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + monkeypatch.setattr( + discoverable_endpoints, + "get_async_httpx_client", + lambda llm_provider: fake_http_client, + ) + request = _mock_callback_request("https://litellm.example.com/") + + try: + response = await discoverable_endpoints.token_endpoint( + request=request, + grant_type="authorization_code", + code="upstream-code", + redirect_uri="http://127.0.0.1:3000/cb", + client_id="unregistered-dcr-client", + ) + finally: + manager.registry.clear() + manager.registry.update(saved_registry) + + assert response.status_code == 200 + assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/oauth/token" + + +@pytest.mark.asyncio +async def test_bridge_authorize_relays_with_registration_url_resolved_by_deferred_discovery(monkeypatch): + """When deferred discovery resolves a DCR-bridge server during the authorize request, the + relay-vs-short-circuit call must read the resolved server: a client that registered itself + through the front door keeps its own redirect binding instead of being routed through the + gateway callback the upstream never granted it.""" + from litellm.proxy._experimental.mcp_server import mcp_server_manager + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + authorize_with_server, + ) + from litellm.types.mcp import MCPAuth, MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="bridge-deferred", + name="bridge_deferred", + server_name="bridge_deferred", + alias="bridge_deferred", + url="https://mcp.example.com/mcp/", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + dcr_bridge=True, + authorization_url=None, + token_url=None, + registration_url=None, + ) + resolved = server.model_copy( + update={ + "authorization_url": "https://idp.example.com/oauth/authorize", + "token_url": "https://idp.example.com/oauth/token", + "registration_url": "https://idp.example.com/oauth/register", + } + ) + + async def resolve_discovery(_srv): + return resolved + + monkeypatch.setattr( + mcp_server_manager.global_mcp_server_manager, + "ensure_oauth_metadata_discovered", + resolve_discovery, + ) + mock_request = MagicMock() + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + response = await authorize_with_server( + request=mock_request, + mcp_server=server, + client_id="front-door-client", + redirect_uri="http://127.0.0.1:60110/client-callback", + state="state456", + code_challenge="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method="S256", + ) + + assert response.status_code == 307 + location = response.headers["location"] + assert location.startswith("https://idp.example.com/oauth/authorize") + assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A60110%2Fclient-callback" in location + + def test_passthrough_authorization_code_round_trips_and_rejects_hostile_input(): """The passthrough gateway code seals and recovers the ephemeral DCR client and upstream code, and is total over hostile input: a raw upstream code opens to None, and a tampered or diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py index 72589fd8b3e..b1aa16a30c0 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py @@ -392,3 +392,22 @@ async def test_invalidate_clears_every_identity_for_a_server(): assert refetched == "tok-after-invalidate" assert mock_client.post.call_count == 3 + + +@pytest.mark.asyncio +async def test_m2m_mint_uses_admin_entered_token_url_when_issuer_yield_empties_resolved(): + """A pinned issuer empties the resolved token_url while configured_token_url keeps the + admin-entered value; the client_credentials mint must POST there instead of raising.""" + server = _server(token_url=None, configured_token_url="https://auth.example.com/token") + cache = MCPOAuth2TokenCache() + mock_client = AsyncMock() + mock_client.post.return_value = _token_response("m2m-token-configured") + + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.get_async_httpx_client", + return_value=mock_client, + ): + result = await cache.async_get_token(server) + + assert result == "m2m-token-configured" + assert mock_client.post.call_args[0][0] == "https://auth.example.com/token"