fix(mcp): fabricate path-aware resource_metadata URI for upstream 401

When MCPUpstreamAuthError.to_http_exception fabricates a
`WWW-Authenticate: Bearer resource_metadata=...` challenge (because
the upstream 401 omitted one), the URL now matches the inbound MCP
transport pattern the client originally used:

  - /mcp/{server_name}      -> /.well-known/oauth-protected-resource/mcp/{server_name}
  - /{server_name}/mcp      -> /.well-known/oauth-protected-resource/{server_name}/mcp

This mirrors the path-aware behaviour of
_get_passthrough_resource_metadata_url in server.py so strict
RFC 9728 \xA73.2 clients on legacy routes get a resource_metadata URI
aligned with the resource pattern they originally targeted.

Co-authored-by: Yassin Kortam <yassin@berri.ai>
This commit is contained in:
Cursor Agent 2026-05-26 19:40:13 +00:00
parent 1d73f1bac5
commit 9469ededc9
No known key found for this signature in database
3 changed files with 41 additions and 15 deletions

View file

@ -28,7 +28,11 @@ 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, base_url: Optional[str] = None) -> HTTPException:
def to_http_exception(
self,
base_url: Optional[str] = None,
request_path: 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
@ -36,21 +40,38 @@ class MCPUpstreamAuthError(Exception):
When the upstream 401 omits ``WWW-Authenticate`` (non-compliant per
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. Callers
must pass ``base_url`` (the gateway origin, no trailing slash) so the
fabricated URI is absolute as RFC 9728 §3.2 requires; if ``base_url``
is missing we skip fabrication entirely rather than emit a relative
URI that strict clients reject in the Bearer challenge.
that points at the gateway's 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. Callers must pass ``base_url``
(the gateway origin, no trailing slash) so the fabricated URI is
absolute as RFC 9728 §3.2 requires; if ``base_url`` is missing we
skip fabrication entirely rather than emit a relative URI that strict
clients reject in the Bearer challenge.
When ``request_path`` is supplied and matches the legacy
``/{server_name}/mcp`` MCP transport route, the fabricated URI uses
the matching legacy well-known form
``/.well-known/oauth-protected-resource/{server_name}/mcp``. Otherwise
we default to the standard form
``/.well-known/oauth-protected-resource/mcp/{server_name}``. This
keeps the ``resource_metadata`` URI aligned with the resource pattern
the client originally targeted, matching the path-aware behaviour of
``_get_passthrough_resource_metadata_url`` in ``server.py``.
"""
challenge: Optional[str] = self.www_authenticate
if challenge is None and self.status_code == 401 and base_url:
prefix = base_url.rstrip("/")
challenge = (
"Bearer resource_metadata="
f'"{prefix}/.well-known/oauth-protected-resource/mcp/{self.server_name}"'
)
if request_path and request_path.startswith(f"/{self.server_name}/mcp"):
resource_metadata_url = (
f"{prefix}/.well-known/oauth-protected-resource/"
f"{self.server_name}/mcp"
)
else:
resource_metadata_url = (
f"{prefix}/.well-known/oauth-protected-resource/"
f"mcp/{self.server_name}"
)
challenge = f'Bearer resource_metadata="{resource_metadata_url}"'
detail = "Forbidden" if self.status_code == 403 else "Unauthorized"
return HTTPException(
status_code=self.status_code,

View file

@ -505,7 +505,10 @@ 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(base_url=get_request_base_url(request))
raise e.to_http_exception(
base_url=get_request_base_url(request),
request_path=request.scope.get("_original_path") or request.url.path,
)
except Exception as e:
verbose_logger.exception(f"Error getting tools from {server.name}: {e}")
return {

View file

@ -3251,7 +3251,8 @@ if MCP_AVAILABLE:
# 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(
base_url=get_request_base_url(StarletteRequest(scope))
base_url=get_request_base_url(StarletteRequest(scope)),
request_path=scope.get("_original_path") or scope.get("path"),
)
except HTTPException:
# Re-raise HTTP exceptions to preserve status codes and details
@ -3356,7 +3357,8 @@ if MCP_AVAILABLE:
# 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(
base_url=get_request_base_url(StarletteRequest(scope))
base_url=get_request_base_url(StarletteRequest(scope)),
request_path=scope.get("_original_path") or scope.get("path"),
)
except HTTPException:
# Re-raise HTTP exceptions to preserve status codes and details