Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py: 59%
87 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"""Per-server outcomes for the aggregate MCP tools/list fan-out.
3The aggregate listing deliberately keeps serving the healthy subset when one server fails, but a
4failed server must contribute a classified outcome instead of silently shrinking the list: an empty
5contribution with no signal makes a broken upstream indistinguishable from a healthy server with no
6tools. Outcomes carry only machine fields (category and status code) so nothing from an upstream
7body crosses the trust boundary; classification is total, so any exception out of a server fetch
8becomes an outcome, never a second failure.
9"""
11from __future__ import annotations
13from collections.abc import Iterator
14from typing import Final, Literal, NamedTuple, NoReturn, TypeAlias
16import httpx
17import httpx2
18from mcp.types import Tool as MCPTool
19from pydantic import BaseModel, ConfigDict
20from typing_extensions import assert_never
22from litellm.proxy._experimental.mcp_server.exceptions import (
23 MCPServerListError,
24 MCPUpstreamAuthError,
25)
26from litellm.proxy._experimental.mcp_server.faults.traversal import iter_exception_tree
28ListFaultCategory: TypeAlias = Literal[
29 "auth_required",
30 "forbidden",
31 "timeout",
32 "unreachable",
33 "upstream_error",
34 "internal",
35]
38class ServerListOk(BaseModel):
39 model_config = ConfigDict(frozen=True)
40 tag: Literal["ok"] = "ok"
41 tool_count: int
44class ServerListFault(BaseModel):
45 """Why a server contributed nothing to a listing: the caller must authenticate upstream
46 (``auth_required``/``forbidden``), the upstream did not answer (``timeout``/``unreachable``),
47 the upstream answered outside its contract (``upstream_error``), or the gateway itself failed
48 (``internal``). ``status_code`` is the upstream HTTP status when one exists."""
50 model_config = ConfigDict(frozen=True)
51 tag: ListFaultCategory
52 status_code: int | None = None
55ServerOutcome: TypeAlias = ServerListOk | ServerListFault
57SERVER_OUTCOMES_META_KEY: Final = "litellm.ai/server_outcomes"
58"""The tools/list result ``_meta`` key carrying per-server outcomes. Prefixed with the litellm.ai
59domain per the MCP spec's ``_meta`` key format so it cannot collide with spec-reserved names."""
62class AggregateToolListing(NamedTuple):
63 tools: list[MCPTool]
64 outcomes: dict[str, ServerOutcome]
67def _iter_upstream_responses(exc: BaseException) -> Iterator[httpx.Response | httpx2.Response]:
68 """Yield every upstream ``httpx``/``httpx2`` ``Response`` in the exception tree, in the shared traversal's deliberate
69 order (explicit causes first, ExceptionGroup members in raise order, the incidental
70 ``__context__`` chain last), so a response raised while handling the real failure can never
71 shadow one on the explicit causal chain. Consumers apply their own predicate over the stream:
72 selecting the first response and THEN testing it would miss a causal auth response sitting
73 behind an unrelated earlier one."""
74 for current in iter_exception_tree(exc):
75 response = getattr(current, "response", None)
76 if isinstance(response, (httpx.Response, httpx2.Response)): 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 yield response
80def _find_upstream_response(exc: BaseException) -> httpx.Response | httpx2.Response | None:
81 return next(_iter_upstream_responses(exc), None)
84def upstream_auth_challenge(exc: BaseException) -> tuple[int, str | None] | None:
85 """The first upstream 401/403 in deliberate order and its ``WWW-Authenticate`` challenge, both
86 read from the SAME response, so the status that picks the carrier channel and the challenge that
87 rides with it can never come from two different responses in the tree. Non-auth responses do not
88 end the scan: a causal 401 behind an unrelated 5xx must still be found, or the client never
89 receives the challenge it needs to re-authenticate."""
90 for response in _iter_upstream_responses(exc): 90 ↛ 91line 90 didn't jump to line 91 because the loop on line 90 never started
91 if response.status_code in (401, 403):
92 return response.status_code, response.headers.get("www-authenticate")
93 return None
96def raise_classified_list_failure(
97 exc: BaseException,
98 server_name: str,
99 suppress_challenge: bool = False,
100) -> NoReturn:
101 """The one place a failed server fetch chooses its carrier: an upstream 401/403 travels as
102 ``MCPUpstreamAuthError`` with the upstream's own challenge preserved (a challenge is only ever
103 fabricated at the HTTP edge, and only for a 401), everything else as ``MCPServerListError`` with
104 a classified fault. Every fetch site delegates here so the two channels cannot drift apart per
105 call site. ``suppress_challenge`` is for dcr_bridge servers, whose upstream challenge points
106 clients at the wrong protected-resource metadata and must never relay."""
107 auth: Final = upstream_auth_challenge(exc)
108 if auth is not None: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 status_code, challenge = auth
110 raise MCPUpstreamAuthError(
111 status_code=status_code,
112 www_authenticate=None if suppress_challenge else challenge,
113 server_name=server_name,
114 ) from exc
115 raise MCPServerListError(classify_list_exception(exc), server_name) from exc
118def classify_list_exception(exc: BaseException) -> ServerListFault:
119 """Classify a per-server listing failure into exactly one outcome. Total: an exception this
120 function cannot recognize is the gateway's own fault (``internal``), never a re-raise."""
121 if isinstance(exc, MCPServerListError) and isinstance(exc.fault, ServerListFault):
122 return exc.fault
123 if isinstance(exc, MCPUpstreamAuthError): 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 tag: Final = "forbidden" if exc.status_code == 403 else "auth_required"
125 return ServerListFault(tag=tag, status_code=exc.status_code)
126 if isinstance(exc, TimeoutError): 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 return ServerListFault(tag="timeout")
128 if isinstance(exc, ConnectionError): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 return ServerListFault(tag="unreachable")
130 auth: Final = upstream_auth_challenge(exc)
131 if auth is not None: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 status_code, _ = auth
133 return ServerListFault(
134 tag="forbidden" if status_code == 403 else "auth_required",
135 status_code=status_code,
136 )
137 response: Final = _find_upstream_response(exc)
138 if response is not None: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 return ServerListFault(tag="upstream_error", status_code=response.status_code)
140 if isinstance(exc, (httpx.TimeoutException, httpx2.TimeoutException)): 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 return ServerListFault(tag="timeout")
142 if isinstance(exc, (httpx.TransportError, httpx2.TransportError)):
143 return ServerListFault(tag="unreachable")
144 return ServerListFault(tag="internal")
147def outcome_wire_value(outcome: ServerOutcome) -> dict[str, object]:
148 """The client-visible form of one outcome, for the tools/list result ``_meta`` and the REST
149 response: category plus status code only, never upstream prose or URLs."""
150 match outcome.tag:
151 case "ok": 151 ↛ 152line 151 didn't jump to line 152 because the pattern on line 151 never matched
152 return {"status": "ok", "tool_count": outcome.tool_count}
153 case "auth_required" | "forbidden" | "timeout" | "unreachable" | "upstream_error" | "internal": 153 ↛ 158line 153 didn't jump to line 158 because the pattern on line 153 always matched
154 return {
155 "status": outcome.tag,
156 **({"http_status": outcome.status_code} if outcome.status_code is not None else {}),
157 }
158 case _:
159 assert_never(outcome.tag)
162def list_fault_http_status(fault: ServerListFault) -> int:
163 """The truthful HTTP status for a single-upstream listing fault per RFC 9110: the upstream's own
164 401/403 for auth, 504 for a timeout, 502 for an unreachable or misbehaving upstream, and 500 only
165 for the gateway's own failure."""
166 match fault.tag:
167 case "auth_required":
168 return fault.status_code or 401
169 case "forbidden":
170 return 403
171 case "timeout":
172 return 504
173 case "unreachable" | "upstream_error":
174 return 502
175 case "internal":
176 return 500
177 case _:
178 assert_never(fault.tag)