mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
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:
parent
1d73f1bac5
commit
9469ededc9
3 changed files with 41 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue