fix(mcp): route delegate_auth_to_upstream servers to upstream OAuth discovery

The oauth-protected-resource metadata endpoint only checked
is_oauth_passthrough when deciding whether to proxy the upstream's
discovery metadata. Servers with auth_type=oauth2 and
delegate_auth_to_upstream=True fell through to the default gateway
metadata, so MCP clients discovered the gateway as the authorization
server instead of the upstream IdP. This broke the PKCE flow: the
gateway intercepted the OAuth callback instead of letting the client
receive the authorization code directly.

Three changes:

1. _build_oauth_protected_resource_response now also tries to fetch
   upstream resource metadata for delegates_interactive_oauth_to_upstream
   servers, with a graceful fallback to gateway-served AS metadata when
   the upstream does not expose the well-known endpoint (unlike
   is_oauth_passthrough which 502s).

2. _build_oauth_authorization_server_response returns the upstream's
   authorization_url and token_url for delegate_auth_to_upstream servers
   so the client authenticates directly with the upstream IdP.

3. register_client_with_server passes through the client's redirect_uris
   for delegate_auth_to_upstream servers instead of substituting the
   gateway's callback URL.

Adds MCPServer.delegates_interactive_oauth_to_upstream property to
encapsulate the repeated auth_type + delegate_auth_to_upstream +
not client_credentials check.

Closes LIT-4120

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-07-01 04:08:48 +00:00 committed by GitHub
parent 70eb4e5d00
commit 0e0dc9871d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 450 additions and 25 deletions

View file

@ -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,
)

View file

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

View file

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