diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 6933aa06b2d..8c2ce9916c6 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -581,12 +581,19 @@ async def register_client_with_server( response_types: Optional[list], token_endpoint_auth_method: Optional[str], fallback_client_id: Optional[str] = None, + client_redirect_uris: Optional[list] = None, ): request_base_url = get_request_base_url(request) + + _is_delegated = mcp_server.delegates_interactive_oauth_to_upstream + effective_redirect_uris = ( + client_redirect_uris if _is_delegated and client_redirect_uris else [f"{request_base_url}/callback"] + ) + dummy_return = { "client_id": fallback_client_id or mcp_server.server_name, "client_secret": "dummy", - "redirect_uris": [f"{request_base_url}/callback"], + "redirect_uris": effective_redirect_uris, } if mcp_server.client_id and mcp_server.client_secret: @@ -600,7 +607,7 @@ async def register_client_with_server( register_data = { "client_name": client_name, - "redirect_uris": [f"{request_base_url}/callback"], + "redirect_uris": effective_redirect_uris, "grant_types": grant_types or [], "response_types": response_types or [], "token_endpoint_auth_method": token_endpoint_auth_method or "", @@ -1030,38 +1037,61 @@ async def _build_oauth_protected_resource_response( else: resource_url = f"{request_base_url}/mcp" - # Pass-through branch: proxy the upstream's own metadata so discovery - # directs the client at the real IdP (Okta, Keycloak, …) instead of us. - if mcp_server is not None and mcp_server.is_oauth_passthrough: + _delegates_to_upstream = mcp_server is not None and ( + mcp_server.is_oauth_passthrough or mcp_server.delegates_interactive_oauth_to_upstream + ) + + if _delegates_to_upstream: + assert mcp_server is not None # narrowing for type checker + upstream_metadata: Optional[dict] = None + fetch_failed = False try: upstream_metadata = await fetch_upstream_oauth_protected_resource(mcp_server) except Exception as exc: verbose_logger.warning( "Failed to fetch upstream oauth-protected-resource metadata " - f"for pass-through MCP server {mcp_server.name!r}: {exc}" - ) - raise HTTPException( - status_code=502, - detail=( - f"Failed to fetch upstream oauth-protected-resource metadata for MCP server {mcp_server.name!r}" - ), + f"for MCP server {mcp_server.name!r}: {exc}" ) + if mcp_server.is_oauth_passthrough: + raise HTTPException( + status_code=502, + detail=( + "Failed to fetch upstream oauth-protected-resource metadata " + f"for MCP server {mcp_server.name!r}" + ), + ) + fetch_failed = True if upstream_metadata is not None: response = {**upstream_metadata, "resource": resource_url} return response - # Upstream responded but with non-200 or non-dict payload. For - # pass-through servers the gateway is NOT the authorization server, - # so we must not fall through to the default gateway metadata — - # that would point clients at the wrong IdP. - verbose_logger.warning( - f"Upstream oauth-protected-resource metadata unavailable for pass-through MCP server {mcp_server.name!r}" - ) - raise HTTPException( - status_code=502, - detail=(f"Upstream oauth-protected-resource metadata unavailable for MCP server {mcp_server.name!r}"), - ) + if mcp_server.is_oauth_passthrough: + verbose_logger.warning( + "Upstream oauth-protected-resource metadata unavailable " + f"for pass-through MCP server {mcp_server.name!r}" + ) + raise HTTPException( + status_code=502, + detail=( + "Upstream oauth-protected-resource metadata unavailable " + f"for MCP server {mcp_server.name!r}" + ), + ) + + if fetch_failed: + verbose_logger.info( + "delegate-auth MCP server %r: upstream resource metadata " + "unavailable, falling back to gateway-served AS metadata", + mcp_server.name, + ) + return { + "authorization_servers": [ + (f"{request_base_url}/{mcp_server_name}" if mcp_server_name else f"{request_base_url}") + ], + "resource": resource_url, + "scopes_supported": (mcp_server.scopes if mcp_server and mcp_server.scopes else []), + } return { "authorization_servers": [ @@ -1149,8 +1179,32 @@ def _build_oauth_authorization_server_response( if mcp_server_name: mcp_server = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name, client_ip=client_ip) + if mcp_server is not None and mcp_server.delegates_interactive_oauth_to_upstream: + if not mcp_server.authorization_url or not mcp_server.token_url: + raise HTTPException( + status_code=500, + detail=( + f"MCP server {mcp_server.name!r} has delegate_auth_to_upstream " + "but missing authorization_url or token_url" + ), + ) + registration_endpoint = mcp_server.registration_url or ( + f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register" + ) + return { + "issuer": request_base_url, + "authorization_endpoint": mcp_server.authorization_url, + "token_endpoint": mcp_server.token_url, + "response_types_supported": ["code"], + "scopes_supported": (mcp_server.scopes if mcp_server.scopes else []), + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "registration_endpoint": registration_endpoint, + } + return { - "issuer": request_base_url, # point to your proxy + "issuer": request_base_url, "authorization_endpoint": authorization_endpoint, "token_endpoint": token_endpoint, "response_types_supported": ["code"], @@ -1158,7 +1212,6 @@ def _build_oauth_authorization_server_response( "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_post"], - # Claude expects a registration endpoint, even if we just fake it "registration_endpoint": ( f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register" ), @@ -1288,6 +1341,7 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non "client_secret": "dummy", "redirect_uris": [f"{request_base_url}/callback"], } + client_redirect_uris = data.get("redirect_uris") client_ip = IPAddressUtils.get_mcp_client_ip(request) if not mcp_server_name: resolved = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) @@ -1300,6 +1354,7 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non response_types=data.get("response_types", []), token_endpoint_auth_method=data.get("token_endpoint_auth_method", ""), fallback_client_id=resolved.server_name or resolved.name, + client_redirect_uris=client_redirect_uris, ) return dummy_return @@ -1314,4 +1369,5 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non response_types=data.get("response_types", []), token_endpoint_auth_method=data.get("token_endpoint_auth_method", ""), fallback_client_id=mcp_server_name, + client_redirect_uris=client_redirect_uris, ) diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index d7c04c09585..bb174c7af9b 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -203,6 +203,21 @@ class MCPServer(BaseModel): return False return any(h.lower() == "authorization" for h in self.extra_headers) + @property + def delegates_interactive_oauth_to_upstream(self) -> bool: + """True when the gateway should point MCP clients at the upstream IdP + for OAuth discovery rather than acting as an OAuth intermediary. + + Requires ``auth_type=oauth2``, ``delegate_auth_to_upstream=True``, and + NOT ``client_credentials`` (M2M servers use stored creds; exposing + them anonymously would be a privilege escalation). + """ + return ( + self.auth_type == MCPAuth.oauth2 + and self.delegate_auth_to_upstream is True + and not self.has_client_credentials + ) + @property def has_token_exchange_config(self) -> bool: """True if this server is configured for OAuth2 token exchange (OBO / RFC 8693).""" 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 b4a26c911dc..aca396cc0a2 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 @@ -3011,3 +3011,357 @@ async def test_token_endpoint_client_secret_basic_without_secret_returns_400(): code_verifier="verifier", ) assert exc_info.value.status_code == 400 + + +# ------------------------------------------------------------------- +# Tests for delegate_auth_to_upstream OAuth discovery (LIT-4120) +# ------------------------------------------------------------------- + + +def _create_delegate_auth_server( + server_id="delegate_server", + name="delegate_mcp", + server_name="delegate_mcp", + url="https://upstream-mcp.example.com/mcp", + authorization_url="https://upstream-idp.example.com/authorize", + token_url="https://upstream-idp.example.com/token", + registration_url=None, + client_id=None, + client_secret=None, +): + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + return MCPServer( + server_id=server_id, + name=name, + server_name=server_name, + url=url, + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + authorization_url=authorization_url, + token_url=token_url, + registration_url=registration_url, + client_id=client_id, + client_secret=client_secret, + scopes=["read"], + ) + + +@pytest.mark.asyncio +async def test_delegate_auth_resource_metadata_proxies_upstream(): + """When upstream has oauth-protected-resource metadata, the gateway + should proxy it (with resource rewritten) for delegate_auth_to_upstream + servers, just like is_oauth_passthrough servers.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_protected_resource_response, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + server = _create_delegate_auth_server() + global_mcp_server_manager.registry[server.server_id] = server + + mock_request = MagicMock() + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = {} + + upstream_metadata = { + "authorization_servers": ["https://upstream-idp.example.com"], + "resource": "https://upstream-mcp.example.com/mcp", + "scopes_supported": ["read", "write"], + } + + try: + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.fetch_upstream_oauth_protected_resource", + new_callable=AsyncMock, + return_value=upstream_metadata, + ): + response = await _build_oauth_protected_resource_response( + request=mock_request, + mcp_server_name="delegate_mcp", + use_standard_pattern=True, + ) + assert response["authorization_servers"] == ["https://upstream-idp.example.com"] + assert response["resource"] == "https://gateway.example.com/mcp/delegate_mcp" + assert response["scopes_supported"] == ["read", "write"] + finally: + global_mcp_server_manager.registry.clear() + + +@pytest.mark.asyncio +async def test_delegate_auth_resource_metadata_fallback_to_gateway(): + """When upstream has no oauth-protected-resource metadata, the gateway + should serve its own resource metadata (with authorization_servers + pointing at the gateway) for delegate_auth_to_upstream servers. + The AS metadata endpoint will return the upstream's endpoints.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_protected_resource_response, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + server = _create_delegate_auth_server() + global_mcp_server_manager.registry[server.server_id] = server + + mock_request = MagicMock() + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = {} + + try: + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.fetch_upstream_oauth_protected_resource", + new_callable=AsyncMock, + return_value=None, + ): + response = await _build_oauth_protected_resource_response( + request=mock_request, + mcp_server_name="delegate_mcp", + use_standard_pattern=True, + ) + assert response["authorization_servers"] == ["https://gateway.example.com/delegate_mcp"] + assert response["resource"] == "https://gateway.example.com/mcp/delegate_mcp" + finally: + global_mcp_server_manager.registry.clear() + + +@pytest.mark.asyncio +async def test_delegate_auth_resource_metadata_fetch_error_falls_back(): + """When fetching upstream metadata fails for a delegate_auth_to_upstream + server, fall back to gateway metadata (unlike passthrough which 502s).""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_protected_resource_response, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + server = _create_delegate_auth_server() + global_mcp_server_manager.registry[server.server_id] = server + + mock_request = MagicMock() + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = {} + + try: + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.fetch_upstream_oauth_protected_resource", + new_callable=AsyncMock, + side_effect=Exception("connection refused"), + ): + response = await _build_oauth_protected_resource_response( + request=mock_request, + mcp_server_name="delegate_mcp", + use_standard_pattern=True, + ) + assert response["authorization_servers"] == ["https://gateway.example.com/delegate_mcp"] + finally: + global_mcp_server_manager.registry.clear() + + +def test_delegate_auth_as_metadata_returns_upstream_endpoints(): + """The authorization server metadata for a delegate_auth_to_upstream + server should return the upstream's authorization/token endpoints, + not the gateway's proxy endpoints.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_authorization_server_response, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + server = _create_delegate_auth_server() + global_mcp_server_manager.registry[server.server_id] = server + + mock_request = MagicMock() + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = {} + + try: + response = _build_oauth_authorization_server_response( + request=mock_request, + mcp_server_name="delegate_mcp", + ) + assert response["authorization_endpoint"] == "https://upstream-idp.example.com/authorize" + assert response["token_endpoint"] == "https://upstream-idp.example.com/token" + assert response["token_endpoint_auth_methods_supported"] == ["none"] + finally: + global_mcp_server_manager.registry.clear() + + +def test_delegate_auth_as_metadata_uses_upstream_registration_url(): + """When the delegate_auth_to_upstream server has a registration_url, + the AS metadata should return it instead of the gateway's.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_authorization_server_response, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + server = _create_delegate_auth_server( + registration_url="https://upstream-idp.example.com/register", + ) + global_mcp_server_manager.registry[server.server_id] = server + + mock_request = MagicMock() + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = {} + + try: + response = _build_oauth_authorization_server_response( + request=mock_request, + mcp_server_name="delegate_mcp", + ) + assert response["registration_endpoint"] == "https://upstream-idp.example.com/register" + finally: + global_mcp_server_manager.registry.clear() + + +def test_delegate_auth_as_metadata_falls_back_to_gateway_register(): + """When the delegate_auth_to_upstream server has no registration_url, + the AS metadata registration_endpoint should point at the gateway.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_authorization_server_response, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + server = _create_delegate_auth_server(registration_url=None) + global_mcp_server_manager.registry[server.server_id] = server + + mock_request = MagicMock() + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = {} + + try: + response = _build_oauth_authorization_server_response( + request=mock_request, + mcp_server_name="delegate_mcp", + ) + assert response["registration_endpoint"] == "https://gateway.example.com/delegate_mcp/register" + finally: + global_mcp_server_manager.registry.clear() + + +@pytest.mark.asyncio +async def test_delegate_auth_register_passes_client_redirect_uris(): + """For delegate_auth_to_upstream servers, the registration dummy + response should use the client's redirect_uris, not the gateway's.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + register_client_with_server, + ) + + server = _create_delegate_auth_server( + client_id="upstream-client", + client_secret="upstream-secret", + ) + + mock_request = MagicMock() + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = {} + + result = await register_client_with_server( + request=mock_request, + mcp_server=server, + client_name="opencode", + grant_types=["authorization_code"], + response_types=["code"], + token_endpoint_auth_method="none", + client_redirect_uris=["http://127.0.0.1:9876/callback"], + ) + assert result["redirect_uris"] == ["http://127.0.0.1:9876/callback"] + + +@pytest.mark.asyncio +async def test_non_delegate_register_uses_gateway_callback(): + """For non-delegate servers, the registration response should + use the gateway's callback URL as before.""" + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + register_client_with_server, + ) + + server = MCPServer( + server_id="normal_oauth", + name="normal_oauth", + server_name="normal_oauth", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="client-id", + client_secret="client-secret", + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + ) + + mock_request = MagicMock() + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = {} + + result = await register_client_with_server( + request=mock_request, + mcp_server=server, + client_name="opencode", + grant_types=["authorization_code"], + response_types=["code"], + token_endpoint_auth_method="none", + client_redirect_uris=["http://127.0.0.1:9876/callback"], + ) + assert result["redirect_uris"] == ["https://gateway.example.com/callback"] + + +def test_delegates_interactive_oauth_to_upstream_property(): + """Verify the MCPServer property correctly identifies servers that + should delegate OAuth to the upstream.""" + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + delegate_server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + ) + assert delegate_server.delegates_interactive_oauth_to_upstream is True + + non_delegate_server = MCPServer( + server_id="s2", + name="s2", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=False, + ) + assert non_delegate_server.delegates_interactive_oauth_to_upstream is False + + m2m_delegate_server = MCPServer( + server_id="s3", + name="s3", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + oauth2_flow="client_credentials", + client_id="cid", + client_secret="csec", + token_url="https://idp.example.com/token", + ) + assert m2m_delegate_server.delegates_interactive_oauth_to_upstream is False + + none_auth_server = MCPServer( + server_id="s4", + name="s4", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + delegate_auth_to_upstream=True, + ) + assert none_auth_server.delegates_interactive_oauth_to_upstream is False