litellm/litellm/proxy/_experimental/mcp_server/exceptions.py
tin-berri e3b4a82ff9
Merge pull request #39926 from BerriAI/litellm_lit6981_none_url_auth
fix(mcp): reject URL credentials for none auth
2026-09-05 18:44:34 +00:00

138 lines
6.5 KiB
Python

"""Exceptions raised by the LiteLLM MCP proxy."""
from typing import Final
from fastapi import HTTPException
class MCPServerURLCredentialsError(HTTPException):
"""A fixed, sanitized URL-credential migration error safe for operator previews."""
def __init__(self) -> None:
super().__init__(
status_code=500,
detail=(
"misconfigured: auth_type none cannot be used with credentials embedded in the upstream URL; "
"remove them from the URL and configure Basic Auth with auth_type: basic and "
"auth_value: username:password"
),
)
class MCPUpstreamAuthError(Exception):
"""Raised when an upstream MCP server returns an authentication failure
(typically HTTP 401) and the gateway should surface it transparently to
the client instead of swallowing it.
Relevant for MCP servers that delegate OAuth to the upstream server,
including pass-through servers and OAuth2 servers with
``delegate_auth_to_upstream`` enabled. The gateway converts this exception
into an HTTP 401 response on single-server routes, preserving any
``WWW-Authenticate`` challenge emitted by the upstream so standards-
compliant MCP clients can trigger the upstream OAuth flow.
"""
def __init__(
self,
status_code: int,
www_authenticate: str | None,
server_name: str,
) -> None:
self.status_code = status_code
self.www_authenticate = www_authenticate
self.server_name = server_name
super().__init__(f"Upstream MCP server {server_name!r} returned {status_code}")
def to_http_exception(
self,
base_url: str | None = None,
request_path: str | None = 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
upstream OAuth flow.
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 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 ``oauth_utils.py``.
"""
challenge: str | None = self.www_authenticate
if challenge is None and self.status_code == 401 and base_url:
prefix: Final = base_url.rstrip("/")
if request_path and request_path.startswith(f"/{self.server_name}/mcp"):
resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/{self.server_name}/mcp"
else:
resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/mcp/{self.server_name}"
challenge = f'Bearer resource_metadata="{resource_metadata_url}"'
detail: Final = "Forbidden" if self.status_code == 403 else "Unauthorized"
return HTTPException(
status_code=self.status_code,
detail=detail,
headers={"www-authenticate": challenge} if challenge else None,
)
class MCPOpenApiUpstreamError(Exception):
"""An OpenAPI-backed MCP tool's upstream answered with a non-2xx that is not a 401.
Carries the status only. The upstream's response body is deliberately dropped rather than served
as tool content: it crosses a trust boundary and may hold prose, urls, or an error document that
reads as data, which is how these failures came to be reported as successful tool output. This
matches ``outcome_wire_value``'s contract for listing faults, category and status and nothing
else. A 401 is raised as ``MCPUpstreamAuthError`` instead, so the caller learns to
re-authenticate; every other status stays here, mirroring the regular MCP path where a 403
deliberately does not produce a challenge.
"""
def __init__(self, status_code: int, server_name: str) -> None:
self.status_code = status_code
self.server_name = server_name
super().__init__(f"upstream returned HTTP {status_code}")
class MCPToolResultError(Exception):
"""An MCP tool call completed with ``isError=True`` in its result.
Never raised on the wire path: streamable HTTP MCP correctly returns tool
failures as HTTP 200 with ``result.isError: true`` per the MCP spec. This
exception only drives the standard failure logging (``status="failure"``
payload, OTel ERROR span) for such results.
Lives here rather than ``utils.py`` deliberately: tests reload ``utils``
to re-read its env-derived constants, and a reload would fork this class
into two identities, breaking ``isinstance`` checks against instances
created before the reload.
"""
class MCPServerListError(Exception):
"""Carrier for a classified per-server listing fault (``faults.list_outcomes.ServerListFault``).
Raised where a server fetch used to silently return an empty tool list, so each boundary can
apply its own policy: the aggregate listing absorbs it into that server's outcome, while
single-server routes relay a truthful HTTP status instead of empty-success. The fault value is
typed as ``object`` here only to avoid a circular import with the faults package; construction
sites always pass a ``ServerListFault``.
"""
def __init__(self, fault: object, server_name: str) -> None:
self.fault = fault
self.server_name = server_name
super().__init__(f"Listing tools from MCP server {server_name!r} failed")