fix(mcp): emit absolute resource_metadata URI in fabricated 401 challenge

Per RFC 9728 §3.2 the resource_metadata Bearer challenge must be an
absolute URI; strict MCP clients reject relative URIs and fail to
initiate discovery. MCPUpstreamAuthError.to_http_exception now accepts
the gateway base URL and prepends it when the upstream omitted
WWW-Authenticate, and all four call sites (streamable HTTP, SSE, and
the two REST tool-list paths) supply it.
This commit is contained in:
mateo-berri 2026-05-21 19:59:15 +00:00
parent 20e644748a
commit ca8e04c05e
No known key found for this signature in database
4 changed files with 35 additions and 7 deletions

View file

@ -28,7 +28,7 @@ class MCPUpstreamAuthError(Exception):
self.server_name = server_name
super().__init__(f"Upstream MCP server {server_name!r} returned {status_code}")
def to_http_exception(self) -> HTTPException:
def to_http_exception(self, base_url: Optional[str] = None) -> HTTPException:
"""Convert this upstream-auth error into an ``HTTPException`` that
preserves the upstream status code and any ``WWW-Authenticate``
challenge, so standards-compliant MCP clients can trigger the
@ -38,13 +38,17 @@ class MCPUpstreamAuthError(Exception):
RFC 7235 §3.1) we fabricate a ``Bearer resource_metadata=`` challenge
that points at the gateway's standard-pattern well-known endpoint for
this server, so MCP clients can still initiate RFC 9728 discovery
against the upstream IdP via the gateway's proxied metadata.
against the upstream IdP via the gateway's proxied metadata. Callers
should pass ``base_url`` (the gateway origin, no trailing slash) so
the fabricated URI is absolute as RFC 9728 §3.2 requires; strict
clients reject relative URIs in the Bearer challenge.
"""
challenge: Optional[str] = self.www_authenticate
if challenge is None and self.status_code == 401:
prefix = base_url.rstrip("/") if base_url else ""
challenge = (
"Bearer resource_metadata="
f'"/.well-known/oauth-protected-resource/mcp/{self.server_name}"'
f'"{prefix}/.well-known/oauth-protected-resource/mcp/{self.server_name}"'
)
return HTTPException(
status_code=self.status_code,

View file

@ -47,6 +47,9 @@ if MCP_AVAILABLE:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.oauth_utils import (
get_request_base_url,
)
from litellm.proxy._experimental.mcp_server.server import (
ListMCPToolsRestAPIResponseObject,
MCPServer,
@ -437,6 +440,7 @@ if MCP_AVAILABLE:
mcp_auth_header: Optional[str],
raw_headers_from_request: dict,
user_api_key_dict: "UserAPIKeyAuth",
request: Request,
) -> dict:
"""
Resolve and fetch tools for a single specified MCP server.
@ -511,7 +515,7 @@ if MCP_AVAILABLE:
except MCPUpstreamAuthError as e:
# Pass-through server returned 401 — surface it to the client so
# standards-compliant MCP clients trigger the upstream OAuth flow.
raise e.to_http_exception()
raise e.to_http_exception(base_url=get_request_base_url(request))
except Exception as e:
verbose_logger.exception(f"Error getting tools from {server.name}: {e}")
return {
@ -536,6 +540,7 @@ if MCP_AVAILABLE:
mcp_auth_header: Optional[str],
raw_headers_from_request: dict,
user_api_key_dict: UserAPIKeyAuth,
request: Request,
) -> dict:
"""Handle tool listing for a single server_id request."""
# Resolve a server name to its UUID if needed
@ -604,7 +609,7 @@ if MCP_AVAILABLE:
except MCPUpstreamAuthError as e:
# Pass-through server returned 401 — surface it to the client so
# standards-compliant MCP clients trigger the upstream OAuth flow.
raise e.to_http_exception()
raise e.to_http_exception(base_url=get_request_base_url(request))
except Exception as e:
verbose_logger.exception(f"Error getting tools from {server.name}: {e}")
return {
@ -692,6 +697,7 @@ if MCP_AVAILABLE:
mcp_auth_header=mcp_auth_header,
raw_headers_from_request=raw_headers_from_request,
user_api_key_dict=user_api_key_dict,
request=request,
)
else:
if not allowed_server_ids:

View file

@ -3231,7 +3231,9 @@ if MCP_AVAILABLE:
except MCPUpstreamAuthError as e:
# Pass-through server returned 401 — surface it to the client so
# standards-compliant MCP clients trigger the upstream OAuth flow.
raise e.to_http_exception()
raise e.to_http_exception(
base_url=get_request_base_url(StarletteRequest(scope))
)
except HTTPException:
# Re-raise HTTP exceptions to preserve status codes and details
raise
@ -3334,7 +3336,9 @@ if MCP_AVAILABLE:
except MCPUpstreamAuthError as e:
# Pass-through server returned 401 — surface it to the client so
# standards-compliant MCP clients trigger the upstream OAuth flow.
raise e.to_http_exception()
raise e.to_http_exception(
base_url=get_request_base_url(StarletteRequest(scope))
)
except HTTPException:
# Re-raise HTTP exceptions to preserve status codes and details
# (e.g. 401 + WWW-Authenticate challenges from OAuth pass-through).

View file

@ -140,6 +140,20 @@ def test_to_http_exception_fabricates_resource_metadata_when_upstream_omits_head
}
def test_to_http_exception_fabricates_absolute_resource_metadata_with_base_url():
err = MCPUpstreamAuthError(
status_code=401,
www_authenticate=None,
server_name="sample_docs",
)
http_exc = err.to_http_exception(base_url="https://gateway.example.com/")
assert http_exc.status_code == 401
assert http_exc.headers == {
"www-authenticate": 'Bearer resource_metadata="https://gateway.example.com/.well-known/oauth-protected-resource/mcp/sample_docs"'
}
def test_to_http_exception_skips_challenge_for_non_401_status():
err = MCPUpstreamAuthError(
status_code=403,