Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/exceptions.py: 42%
32 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""Exceptions raised by the LiteLLM MCP proxy."""
3from typing import Final
5from fastapi import HTTPException
8class MCPServerURLCredentialsError(HTTPException):
9 """A fixed, sanitized URL-credential migration error safe for operator previews."""
11 def __init__(self) -> None:
12 super().__init__(
13 status_code=500,
14 detail=(
15 "misconfigured: auth_type none cannot be used with credentials embedded in the upstream URL; "
16 "remove them from the URL and configure Basic Auth with auth_type: basic and "
17 "auth_value: username:password"
18 ),
19 )
22class MCPUpstreamAuthError(Exception):
23 """Raised when an upstream MCP server returns an authentication failure
24 (typically HTTP 401) and the gateway should surface it transparently to
25 the client instead of swallowing it.
27 Relevant for MCP servers that delegate OAuth to the upstream server,
28 including pass-through servers and OAuth2 servers with
29 ``delegate_auth_to_upstream`` enabled. The gateway converts this exception
30 into an HTTP 401 response on single-server routes, preserving any
31 ``WWW-Authenticate`` challenge emitted by the upstream so standards-
32 compliant MCP clients can trigger the upstream OAuth flow.
33 """
35 def __init__(
36 self,
37 status_code: int,
38 www_authenticate: str | None,
39 server_name: str,
40 ) -> None:
41 self.status_code = status_code
42 self.www_authenticate = www_authenticate
43 self.server_name = server_name
44 super().__init__(f"Upstream MCP server {server_name!r} returned {status_code}")
46 def to_http_exception(
47 self,
48 base_url: str | None = None,
49 request_path: str | None = None,
50 ) -> HTTPException:
51 """Convert this upstream-auth error into an ``HTTPException`` that
52 preserves the upstream status code and any ``WWW-Authenticate``
53 challenge, so standards-compliant MCP clients can trigger the
54 upstream OAuth flow.
56 When the upstream 401 omits ``WWW-Authenticate`` (non-compliant per
57 RFC 7235 §3.1) we fabricate a ``Bearer resource_metadata=`` challenge
58 that points at the gateway's well-known endpoint for this server, so
59 MCP clients can still initiate RFC 9728 discovery against the upstream
60 IdP via the gateway's proxied metadata. Callers must pass ``base_url``
61 (the gateway origin, no trailing slash) so the fabricated URI is
62 absolute as RFC 9728 §3.2 requires; if ``base_url`` is missing we
63 skip fabrication entirely rather than emit a relative URI that strict
64 clients reject in the Bearer challenge.
66 When ``request_path`` is supplied and matches the legacy
67 ``/{server_name}/mcp`` MCP transport route, the fabricated URI uses
68 the matching legacy well-known form
69 ``/.well-known/oauth-protected-resource/{server_name}/mcp``. Otherwise
70 we default to the standard form
71 ``/.well-known/oauth-protected-resource/mcp/{server_name}``. This
72 keeps the ``resource_metadata`` URI aligned with the resource pattern
73 the client originally targeted, matching the path-aware behaviour of
74 ``get_passthrough_resource_metadata_url`` in ``oauth_utils.py``.
75 """
76 challenge: str | None = self.www_authenticate
77 if challenge is None and self.status_code == 401 and base_url:
78 prefix: Final = base_url.rstrip("/")
79 if request_path and request_path.startswith(f"/{self.server_name}/mcp"):
80 resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/{self.server_name}/mcp"
81 else:
82 resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/mcp/{self.server_name}"
83 challenge = f'Bearer resource_metadata="{resource_metadata_url}"'
84 detail: Final = "Forbidden" if self.status_code == 403 else "Unauthorized"
85 return HTTPException(
86 status_code=self.status_code,
87 detail=detail,
88 headers={"www-authenticate": challenge} if challenge else None,
89 )
92class MCPOpenApiUpstreamError(Exception):
93 """An OpenAPI-backed MCP tool's upstream answered with a non-2xx that is not a 401.
95 Carries the status only. The upstream's response body is deliberately dropped rather than served
96 as tool content: it crosses a trust boundary and may hold prose, urls, or an error document that
97 reads as data, which is how these failures came to be reported as successful tool output. This
98 matches ``outcome_wire_value``'s contract for listing faults, category and status and nothing
99 else. A 401 is raised as ``MCPUpstreamAuthError`` instead, so the caller learns to
100 re-authenticate; every other status stays here, mirroring the regular MCP path where a 403
101 deliberately does not produce a challenge.
102 """
104 def __init__(self, status_code: int, server_name: str) -> None:
105 self.status_code = status_code
106 self.server_name = server_name
107 super().__init__(f"upstream returned HTTP {status_code}")
110class MCPToolResultError(Exception):
111 """An MCP tool call completed with ``isError=True`` in its result.
113 Never raised on the wire path: streamable HTTP MCP correctly returns tool
114 failures as HTTP 200 with ``result.isError: true`` per the MCP spec. This
115 exception only drives the standard failure logging (``status="failure"``
116 payload, OTel ERROR span) for such results.
118 Lives here rather than ``utils.py`` deliberately: tests reload ``utils``
119 to re-read its env-derived constants, and a reload would fork this class
120 into two identities, breaking ``isinstance`` checks against instances
121 created before the reload.
122 """
125class MCPServerListError(Exception):
126 """Carrier for a classified per-server listing fault (``faults.list_outcomes.ServerListFault``).
128 Raised where a server fetch used to silently return an empty tool list, so each boundary can
129 apply its own policy: the aggregate listing absorbs it into that server's outcome, while
130 single-server routes relay a truthful HTTP status instead of empty-success. The fault value is
131 typed as ``object`` here only to avoid a circular import with the faults package; construction
132 sites always pass a ``ServerListFault``.
133 """
135 def __init__(self, fault: object, server_name: str) -> None:
136 self.fault = fault
137 self.server_name = server_name
138 super().__init__(f"Listing tools from MCP server {server_name!r} failed")