The stripper was an AsyncClient subclass minted before the client kwargs were assembled, so the class the transport constructed was no longer the SDK httpx AsyncClient the caller sees (tests/tools/test_mcp_http_proxy.py patches it to assert both builders and the preflight probe carry the proxy mounts next to the body cap - KeyError: mounts on CI). Return a factory that constructs httpx_mod.AsyncClient(**kwargs) and installs the cross-origin _build_redirect_request boundary on that instance instead: the same strip rules apply (TestRedirectHeaderStripper unchanged), mounts/transport/headers reach the real client class, and the proxy-mount tests observe them again.
513 lines
27 KiB
Python
513 lines
27 KiB
Python
"""MCP connection/transport error classification: URL validation, TLS client certs, identity
|
|
headers, redirect header stripping, exception-group unwrapping, auth/session-expired/
|
|
method-not-found detection and connect-error formatting. Split from tools/mcp_tool.py."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import errno
|
|
import importlib
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import Any, List, Optional
|
|
from urllib.parse import urlparse
|
|
from tools.mcp_tool_common import _sanitize_error, _core
|
|
|
|
logger = logging.getLogger("tools.mcp_tool")
|
|
|
|
# Stateless (2026-07-28) servers reject a legacy ``initialize`` with this or plain method-not-found.
|
|
_JSONRPC_UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
|
|
|
|
def _jsonrpc_matches(exc: BaseException, codes: tuple, markers: tuple, code=None) -> bool:
|
|
"""Structural ``MCPError.error.code`` (or *code*) in *codes*, else any *marker* in ``str(exc).lower()``. Never
|
|
``isinstance`` on SDK exception types: they arrive wrapped in ExceptionGroups and drift across generations."""
|
|
code = getattr(getattr(exc, "error", None), "code", None) or code
|
|
return code in codes or any(marker in str(exc).lower() for marker in markers)
|
|
|
|
|
|
def _handshake_rejected_as_modern(exc: BaseException) -> bool:
|
|
"""True when a failed ``initialize`` signals a stateless-only (2026-07-28) server."""
|
|
return _jsonrpc_matches(
|
|
exc, (_JSONRPC_UNSUPPORTED_PROTOCOL_VERSION, _core._JSONRPC_METHOD_NOT_FOUND),
|
|
("unsupported protocol version", str(_JSONRPC_UNSUPPORTED_PROTOCOL_VERSION)),
|
|
code=getattr(exc, "code", None)) or _is_method_not_found_error(exc)
|
|
|
|
|
|
def _handshake_answered_with_unsupported_version(exc: BaseException) -> bool:
|
|
"""True when ``initialize`` SUCCEEDED on the wire (HTTP 200, a valid InitializeResult) but the SDK
|
|
refused the ``protocolVersion`` the server named — its ``RuntimeError("Unsupported protocol version
|
|
from the server: ...")``. Distinct from a JSON-RPC -32022 rejection, where the server refused us."""
|
|
return "unsupported protocol version from the server" in str(_unwrap_exception_group(exc)).lower()
|
|
|
|
|
|
def _is_method_not_found_error(exc: BaseException) -> bool:
|
|
"""True if *exc* is a JSON-RPC ``method not found`` (-32601; ``ping`` is optional in MCP). The
|
|
substring fallback includes "Unknown method: <name>" — without it the ping→list_tools keepalive
|
|
fallback never latches and reconnect-loops.
|
|
|
|
The substring fallback matters when a server reports method-not-found without a structural ``-32601``
|
|
code (e.g. surfaced as a plain exception string). Besides the canonical "method not found", many
|
|
JSON-RPC implementations phrase it as "Unknown method: <name>" — agentmemory's MCP server is one such
|
|
case (#50028).
|
|
"""
|
|
return _jsonrpc_matches(
|
|
exc, (_core._JSONRPC_METHOD_NOT_FOUND,),
|
|
(str(_core._JSONRPC_METHOD_NOT_FOUND), "method not found", "unknown method", "not found: ping"))
|
|
|
|
|
|
class InvalidMcpUrlError(ValueError):
|
|
"""A remote MCP server's ``url`` is not parseable http(s):// — validated once at startup to fail fast.
|
|
|
|
Validated once at startup so we fail fast with a clear message instead of burning through the
|
|
reconnect-backoff loop on every attempt. (Ported from anomalyco/opencode#25019.)
|
|
"""
|
|
|
|
|
|
class NonMcpEndpointError(ConnectionError):
|
|
"""An HTTP MCP URL served a non-MCP 2xx (e.g. ``text/html``). Non-retryable: every attempt gets
|
|
the same page, so backoff is skipped and the server fails immediately. Subclasses ConnectionError
|
|
so broad catches still see a connection problem."""
|
|
|
|
|
|
# Streamable-HTTP rejection statuses an SSE-only server (or its load balancer) produces for the
|
|
# chunked ``initialize`` POST: Bad Request, Method Not Allowed, Not Acceptable, Length Required.
|
|
_STREAMABLE_REJECT_STATUSES = (400, 405, 406, 411)
|
|
|
|
|
|
def _is_streamable_http_rejection(exc: BaseException) -> bool:
|
|
"""True when a Streamable-HTTP connect failure looks like a transport mismatch rather than a
|
|
broken server: a 400-family rejection of the initialize POST, or the SDK's opaque INTERNAL_ERROR
|
|
(-32603 ``Server returned an error response``) it maps such rejections to on mcp >= 2.0 (error
|
|
class per PR #104363, @RohithPariki). Timeouts and auth errors never qualify — neither carries
|
|
these markers — so a slow or 401ing server is not retried on the wrong transport.
|
|
"""
|
|
root = _unwrap_exception_group(exc)
|
|
if getattr(getattr(root, "response", None), "status_code", None) in _STREAMABLE_REJECT_STATUSES:
|
|
return True
|
|
code = getattr(getattr(root, "error", None), "code", None)
|
|
return code == -32603 and "server returned an error response" in str(root).lower()
|
|
|
|
|
|
_HTTP_REJECTION_BODY_CHARS = 300
|
|
|
|
|
|
def _make_http_rejection_recorder(sink: dict):
|
|
"""httpx response hook for the owned Streamable HTTP client: remembers the last 4xx/5xx the server
|
|
sent (status, method, URL, head of the body). mcp >= 2.0 folds a non-2xx whose body it cannot
|
|
parse as a JSON-RPC error into the opaque ``-32603 Server returned an error response`` — the
|
|
status and the server's own words (e.g. ``400 {"code":-32020,"message":"Unsupported
|
|
MCP-Protocol-Version"}``) never reach the exception, so this is the only place they can be
|
|
observed. SSE bodies are never read (a stream would block the hook)."""
|
|
|
|
async def _record(response):
|
|
if response.status_code < 400:
|
|
return
|
|
body = ""
|
|
if response.headers.get("content-type", "").split(";")[0].strip().lower() != "text/event-stream":
|
|
try:
|
|
raw = await response.aread() # buffered: the SDK's own aread() afterwards sees the same bytes
|
|
body = " ".join(raw[:_HTTP_REJECTION_BODY_CHARS * 4].decode("utf-8", "replace").split())
|
|
except Exception: # the failure itself is still reported, just without the body
|
|
body = ""
|
|
sink.update(status=response.status_code, method=response.request.method,
|
|
url=str(response.request.url), body=body[:_HTTP_REJECTION_BODY_CHARS])
|
|
|
|
return _record
|
|
|
|
|
|
def _describe_http_failure(exc: BaseException, rejection: dict) -> str:
|
|
"""``str(root cause)`` of a Streamable HTTP connect failure; when that root is the SDK's opaque
|
|
``-32603 Server returned an error response`` and the recorder saw the rejection, the HTTP status,
|
|
request URL and body head are appended so the message names what the server actually said."""
|
|
root = _unwrap_exception_group(exc)
|
|
text = str(root)
|
|
opaque = (getattr(getattr(root, "error", None), "code", None) == -32603
|
|
and "server returned an error response" in text.lower())
|
|
if not (opaque and rejection):
|
|
return text
|
|
detail = f"HTTP {rejection['status']} from {rejection['method']} {rejection['url']}"
|
|
if rejection["body"]:
|
|
detail += f": {rejection['body']}"
|
|
return f"{text} ({detail})"
|
|
|
|
|
|
def _unwrap_exception_group(exc: BaseException) -> BaseException:
|
|
"""Root-cause leaf of anyio ``(Base)ExceptionGroup`` wrappers (group ``str()`` is opaque). A
|
|
``KeyboardInterrupt``/``SystemExit`` leaf anywhere is re-raised, never flattened into a loggable
|
|
error; a non-cancellation leaf is preferred over the ``CancelledError`` noise anyio sprays on siblings."""
|
|
while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
|
|
leaf: BaseException = exc.split((KeyboardInterrupt, SystemExit))[0]
|
|
if leaf is not None:
|
|
while isinstance(leaf, BaseExceptionGroup) and leaf.exceptions:
|
|
leaf = leaf.exceptions[0]
|
|
raise leaf
|
|
exc = next((sub for sub in exc.exceptions if not _contains_only_cancellation(sub)), exc.exceptions[0])
|
|
return exc
|
|
|
|
|
|
def _contains_only_cancellation(exc: BaseException) -> bool:
|
|
"""True if ``exc`` is (or a group containing only) CancelledError."""
|
|
if isinstance(exc, BaseExceptionGroup):
|
|
return all(_contains_only_cancellation(sub) for sub in exc.exceptions)
|
|
return isinstance(exc, asyncio.CancelledError)
|
|
|
|
|
|
def _classify_mcp_failure(exc: BaseException) -> str:
|
|
"""``'permanent'`` (``run()`` parks instead of burning the retry ladder: auth 401/403,
|
|
NonMcpEndpointError, InvalidMcpUrlError, missing stdio command) or ``'transient'`` (backoff retry)."""
|
|
root = _unwrap_exception_group(exc)
|
|
permanent = (_is_auth_error(root)
|
|
or isinstance(root, (NonMcpEndpointError, InvalidMcpUrlError, FileNotFoundError))
|
|
or (isinstance(root, OSError) and getattr(root, "errno", None) == errno.ENOENT)
|
|
# 401/403 HTTPStatusError that _is_auth_error's type-gate missed (auth types not importable here)
|
|
or getattr(getattr(root, "response", None), "status_code", None) in (401, 403))
|
|
return "permanent" if permanent else "transient"
|
|
|
|
|
|
def _validate_remote_mcp_url(server_name: str, url: Any) -> str:
|
|
"""The stripped URL if valid http(s); else InvalidMcpUrlError naming the server (non-string, other scheme —
|
|
stdio servers use ``command`` — or empty host)."""
|
|
def _bad(detail: str) -> InvalidMcpUrlError:
|
|
return InvalidMcpUrlError(f"Invalid MCP URL for '{server_name}': {detail}")
|
|
if not isinstance(url, str):
|
|
raise _bad(f"expected a string, got {type(url).__name__}")
|
|
stripped = url.strip()
|
|
if not stripped:
|
|
raise _bad("empty url")
|
|
try:
|
|
parsed = urlparse(stripped)
|
|
except Exception as exc: # urlparse is very permissive — belt and braces
|
|
raise _bad(f"{stripped!r} ({exc})") from exc
|
|
if parsed.scheme.lower() not in {"http", "https"}:
|
|
raise _bad(f"scheme must be http or https, got {parsed.scheme!r} ({stripped!r})")
|
|
if not parsed.netloc:
|
|
raise _bad(f"missing host ({stripped!r})")
|
|
if not parsed.hostname: # ``urlparse`` accepts ``http://:8080`` (empty host, explicit port)
|
|
raise _bad(f"missing hostname ({stripped!r})")
|
|
return stripped
|
|
|
|
|
|
def _resolve_client_cert(server_name: str, config: dict):
|
|
"""``client_cert`` / ``client_key`` in httpx's ``cert=`` shape: None, a combined-PEM path,
|
|
``(cert, key)`` or ``(cert, key, password)``. ``~`` is expanded; missing files raise a
|
|
server-scoped FileNotFoundError instead of an opaque TLS handshake error."""
|
|
raw_cert = config.get("client_cert")
|
|
raw_key = config.get("client_key")
|
|
if raw_cert is None and raw_key is None:
|
|
return None
|
|
prefix = f"MCP server '{server_name}': "
|
|
|
|
def _expand(path: Any, label: str) -> str:
|
|
if not isinstance(path, str) or not path.strip():
|
|
raise ValueError(f"{prefix}{label} must be a non-empty string path (got {type(path).__name__})")
|
|
expanded = os.path.expanduser(path.strip())
|
|
if not os.path.isfile(expanded):
|
|
raise FileNotFoundError(f"{prefix}{label} not found at {expanded!r}")
|
|
return expanded
|
|
if not isinstance(raw_cert, (list, tuple)):
|
|
cert_path = _expand(raw_cert, "client_cert")
|
|
return (cert_path, _expand(raw_key, "client_key")) if raw_key is not None else cert_path # combined PEM
|
|
if raw_key is not None:
|
|
raise ValueError(f"{prefix}specify either client_cert as a list [cert, key] OR client_cert + client_key, not both")
|
|
if len(raw_cert) not in (2, 3):
|
|
raise ValueError(f"{prefix}client_cert list form must have 2 or 3 elements (got {len(raw_cert)})")
|
|
pair = (_expand(raw_cert[0], "client_cert[0]"), _expand(raw_cert[1], "client_cert[1]"))
|
|
if len(raw_cert) == 2:
|
|
return pair
|
|
if not isinstance(raw_cert[2], str):
|
|
raise ValueError(f"{prefix}client_cert[2] (key passphrase) must be a string")
|
|
return (*pair, raw_cert[2])
|
|
|
|
|
|
def _resolve_identity_header(server_name: str, config: dict):
|
|
"""``identity_header`` ``{name, value_from: "static"|"profile", value}`` → ``(name, value)`` or
|
|
None. Invalid configs warn and are ignored — an identity header must never break the connection.
|
|
``profile`` resolves once at connect time."""
|
|
raw = config.get("identity_header")
|
|
if raw is None:
|
|
return None
|
|
|
|
def _ignore(detail: str, *args):
|
|
logger.warning("MCP server '%s': identity_header " + detail + " — ignoring", server_name, *args)
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return _ignore("must be a mapping with 'name' and 'value'/'value_from' keys (got %s)", type(raw).__name__)
|
|
name = raw.get("name")
|
|
if not isinstance(name, str) or not name.strip():
|
|
return _ignore("requires a non-empty 'name'")
|
|
value_from = (raw.get("value_from") or "static").strip().lower()
|
|
if value_from == "profile":
|
|
from hermes_cli.profiles import get_active_profile_name
|
|
return (name.strip(), get_active_profile_name())
|
|
if value_from != "static":
|
|
return _ignore("value_from must be 'static' or 'profile' (got %r)", value_from)
|
|
value = raw.get("value")
|
|
if not isinstance(value, str) or not value.strip():
|
|
return _ignore("with value_from: static requires a non-empty string 'value'")
|
|
return (name.strip(), value)
|
|
|
|
|
|
def _apply_identity_header(server_name: str, config: dict, headers: dict) -> dict:
|
|
"""Merge the identity header into ``headers`` in place; an explicit entry of the same name (any
|
|
casing) wins — never silently override user config."""
|
|
name, value = _resolve_identity_header(server_name, config) or (None, None)
|
|
if name is None:
|
|
return headers
|
|
if any(key.lower() == name.lower() for key in headers):
|
|
logger.debug("MCP server '%s': identity_header '%s' already set via explicit "
|
|
"headers config — keeping the explicit value", server_name, name)
|
|
else:
|
|
headers[name] = value
|
|
return headers
|
|
|
|
|
|
def _make_redirect_header_stripper(httpx_mod, original_url, *, strict: bool = False,
|
|
configured_header_names: "set[str] | frozenset[str]" = frozenset()):
|
|
"""Client factory enforcing the redirect credential boundary: on a cross-origin redirect
|
|
follow-up it strips ``Authorization``; with *strict* (Agent Plugins v1 ``strict_redirect_headers``)
|
|
every configured header (lowercase names in *configured_header_names*) is stripped too — v1 forbids
|
|
forwarding them cross-origin.
|
|
|
|
The factory builds ``httpx_mod.AsyncClient(**kwargs)`` — resolved at call time, so the proxy
|
|
``mounts=`` / ``transport=`` the caller passes reach the SDK's real client class (and anything a
|
|
caller swapped in for it) unchanged — and installs the boundary on that instance's
|
|
``_build_redirect_request``. This MUST live on ``_build_redirect_request``: ``response.next_request``
|
|
is unset when response event hooks fire (httpx populates it later in the redirect loop), so a
|
|
response hook can never mutate the follow-up; and a *request* hook would fire on non-redirect traffic
|
|
too — the OAuth auth flow yields token/metadata/registration requests through the same client, often
|
|
to a different-origin authorization server whose own credentials must NOT be stripped."""
|
|
origin = (original_url.scheme, original_url.host, original_url.port)
|
|
|
|
def _build_client(**kwargs):
|
|
client = httpx_mod.AsyncClient(**kwargs)
|
|
base_build = getattr(type(client), "_build_redirect_request", None)
|
|
|
|
def _build_redirect_request(request, response):
|
|
next_request = base_build(client, request, response)
|
|
target = next_request.url
|
|
if (target.scheme, target.host, target.port) != origin:
|
|
headers = next_request.headers
|
|
headers.pop("authorization", None)
|
|
headers.pop("Authorization", None)
|
|
for _name in configured_header_names if strict else ():
|
|
while _name in headers:
|
|
del headers[_name]
|
|
return next_request
|
|
|
|
client._build_redirect_request = _build_redirect_request
|
|
return client
|
|
|
|
return _build_client
|
|
|
|
|
|
# Wire-body cap, applied at the httpx transport before the SDK buffers/JSON-parses a response. A
|
|
# hostile or misbehaving remote MCP server can stream an unbounded catalog/tool-result body and none
|
|
# of the post-parse limits (resource cap, tool-result truncation) run before the parse blows up.
|
|
# Finite HTTP bodies are capped at this many bytes (a larger Content-Length is rejected up front);
|
|
# each SSE *event* is capped, with the counter reset at completed event boundaries so a long-lived
|
|
# stream and its keepalives have no cumulative limit. Violations raise the SDK httpx's ReadError and
|
|
# flow through the ordinary transport teardown/reconnect path (#66092).
|
|
_MCP_HTTP_MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
# An SSE event ends at a blank line: two consecutive line terminators. The spec allows CR,
|
|
# LF, or CRLF terminators and permits mixing them, so the boundary is any of \n\n, \r\r,
|
|
# \n\r, \r\n\r\n, \r\n\n, \r\n\r, \n\r\n, \r\r\n. "\r\n" alone is ONE terminator, not two:
|
|
# the lookahead keeps a plain CRLF line ending from backtracking into a \r + \n boundary.
|
|
_SSE_BOUNDARY_RE = re.compile(rb"(?:\r\n|\r(?!\n)|\n){2}")
|
|
_SSE_BOUNDARY_CARRY = 3 # longest boundary ("\r\n\r\n") minus one byte
|
|
|
|
|
|
def _make_mcp_body_cap_transport(httpx_mod, inner_transport, limit: int = _MCP_HTTP_MAX_BODY_BYTES):
|
|
"""Wrap ``inner_transport`` so every response body is size-capped. ``httpx_mod`` must be the SDK's
|
|
own httpx module (``sdk_httpx()``): the transport is handed to that SDK's ``AsyncClient``."""
|
|
|
|
class _CappedStream(httpx_mod.AsyncByteStream):
|
|
def __init__(self, inner, is_sse: bool, url: str):
|
|
self._inner, self._is_sse, self._url = inner, is_sse, url
|
|
|
|
def _reject(self, kind: str):
|
|
return httpx_mod.ReadError(f"MCP {kind} exceeds {limit} bytes (from {self._url})")
|
|
|
|
async def __aiter__(self):
|
|
counted = 0
|
|
tail = b"" # last _SSE_BOUNDARY_CARRY stream bytes; a boundary can straddle chunks
|
|
async for chunk in self._inner:
|
|
if self._is_sse:
|
|
# Charge each completed event once: the carried prefix plus bytes up to its
|
|
# boundary must fit the cap, then the next event starts after it. Scan the
|
|
# carried suffix plus this chunk so a boundary split across chunks is still
|
|
# seen; bytes before len(tail) were already counted into `counted`.
|
|
window = tail + chunk
|
|
pos = 0
|
|
for match in _SSE_BOUNDARY_RE.finditer(window):
|
|
end = match.end()
|
|
if end <= len(tail):
|
|
continue # boundary completed inside the carried suffix: already counted
|
|
if counted + end - max(pos, len(tail)) > limit:
|
|
raise self._reject("SSE event")
|
|
counted, pos = 0, end
|
|
counted += len(window) - max(pos, len(tail))
|
|
tail = window[-_SSE_BOUNDARY_CARRY:]
|
|
else:
|
|
counted += len(chunk)
|
|
if counted > limit:
|
|
raise self._reject("SSE event" if self._is_sse else "HTTP response")
|
|
yield chunk
|
|
|
|
async def aclose(self):
|
|
await self._inner.aclose()
|
|
|
|
class _BodyCapTransport(httpx_mod.AsyncBaseTransport):
|
|
def __init__(self, inner):
|
|
self._inner = inner
|
|
|
|
async def handle_async_request(self, request):
|
|
response = await self._inner.handle_async_request(request)
|
|
declared = response.headers.get("content-length")
|
|
with contextlib.suppress(ValueError): # malformed header: the streamed cap still applies
|
|
if declared is not None and int(declared) > limit:
|
|
await response.aclose()
|
|
raise httpx_mod.ReadError(f"MCP HTTP response declares Content-Length {declared} > {limit} "
|
|
f"bytes cap (from {request.url})")
|
|
is_sse = "text/event-stream" in response.headers.get("content-type", "").lower()
|
|
response.stream = _CappedStream(response.stream, is_sse, str(request.url))
|
|
return response
|
|
|
|
async def aclose(self):
|
|
await self._inner.aclose()
|
|
|
|
return _BodyCapTransport(inner_transport)
|
|
|
|
|
|
# Node budget for ``_iter_exception_nodes`` (the visited set breaks cycles; this bounds acyclic blow-ups).
|
|
# Well above ``sys.getrecursionlimit()`` so deep task-group nesting is fully scanned.
|
|
_EXC_TRAVERSAL_MAX_NODES = 10_000
|
|
|
|
|
|
def _exc_children(exc: BaseException) -> List[BaseException]:
|
|
"""A group's sub-exceptions (if any) followed by ``__cause__``/``__context__`` when they are exceptions — a
|
|
group raised inside an ``except`` block carries the caught error as ``__context__``, so the chain is never
|
|
skipped."""
|
|
nested = getattr(exc, "exceptions", None) or ()
|
|
return [*nested, *(c for c in (exc.__cause__, exc.__context__) if isinstance(c, BaseException))]
|
|
|
|
|
|
def _iter_exception_nodes(exc: BaseException) -> List[BaseException]:
|
|
"""Pre-order, left-to-right walk of an exception tree/chain, each node once. ``__cause__``/``__context__``
|
|
can point back at an ancestor (a raised-and-caught pair does this routinely, e.g. the same OAuth error
|
|
raised on the Streamable-HTTP attempt and again on the SSE fallback), so a naive recursive walk dies with
|
|
RecursionError and hides the real connect error; the visited set breaks cycles, the budget bounds acyclic
|
|
blow-ups."""
|
|
stack = [exc]
|
|
seen: set[int] = set()
|
|
ordered: List[BaseException] = []
|
|
while stack and len(ordered) < _EXC_TRAVERSAL_MAX_NODES:
|
|
current = stack.pop()
|
|
if id(current) in seen:
|
|
continue
|
|
seen.add(id(current))
|
|
ordered.append(current)
|
|
stack.extend(reversed(_exc_children(current)))
|
|
return ordered
|
|
|
|
|
|
def _format_connect_error(exc: BaseException) -> str:
|
|
"""Render nested MCP connection errors into an actionable short message."""
|
|
nodes = _iter_exception_nodes(exc)
|
|
|
|
def _find_missing() -> Optional[str]:
|
|
for current in nodes:
|
|
if isinstance(current, FileNotFoundError):
|
|
if getattr(current, "filename", None):
|
|
return str(current.filename)
|
|
match = re.search(r"No such file or directory: '([^']+)'", str(current))
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
def _flatten_messages() -> List[str]:
|
|
messages: List[str] = []
|
|
for current in nodes:
|
|
# A group's own str() is opaque — only its children speak; a message-less leaf still names its type.
|
|
text = "" if getattr(current, "exceptions", None) else str(current).strip()
|
|
if text:
|
|
messages.append(text)
|
|
elif not _exc_children(current):
|
|
messages.append(current.__class__.__name__)
|
|
return messages or [exc.__class__.__name__]
|
|
|
|
missing = _find_missing()
|
|
if not missing:
|
|
return _sanitize_error("; ".join(list(dict.fromkeys(_flatten_messages()))[:3]))
|
|
message = f"missing executable '{missing}'"
|
|
if os.path.basename(missing) in {"npx", "npm", "node"}:
|
|
message += (" (ensure Node.js is installed and PATH includes its bin directory, "
|
|
"or set mcp_servers.<name>.command to an absolute path and include "
|
|
"that directory in mcp_servers.<name>.env.PATH)")
|
|
return _sanitize_error(message)
|
|
|
|
|
|
def _optional_types(module: str, *names: str) -> list:
|
|
"""``[module.name, ...]`` or ``[]`` when the module/attribute is unavailable."""
|
|
try:
|
|
mod = importlib.import_module(module)
|
|
return [getattr(mod, name) for name in names]
|
|
except (ImportError, AttributeError):
|
|
return []
|
|
|
|
|
|
# Lazily-built ``(auth_types, http_status_types)`` so this module imports without the SDK OAuth module.
|
|
_AUTH_ERROR_TYPES: Optional[tuple] = None
|
|
|
|
|
|
def _get_auth_error_types() -> tuple:
|
|
"""Cached ``(auth_types, http_status_types)``: SDK ``OAuthFlowError``/``OAuthTokenError`` (+ legacy
|
|
``UnauthorizedError``), our ``OAuthNonInteractiveError``, and ``HTTPStatusError`` from both httpx
|
|
flavours — a 401 may come from the SDK's own stack (``httpx2`` on mcp >= 2.0) or Hermes' pinned
|
|
``httpx``; the classes are unrelated and still need the 401 check in :func:`_is_auth_error`."""
|
|
global _AUTH_ERROR_TYPES
|
|
if not (_AUTH_ERROR_TYPES and _AUTH_ERROR_TYPES[0]): # retry while empty (SDK may import later)
|
|
sdk_mod = _core.sdk_httpx()
|
|
http_types = tuple(dict.fromkeys(
|
|
([sdk_mod.HTTPStatusError] if sdk_mod is not None else []) + _optional_types("httpx", "HTTPStatusError")))
|
|
auth_types = (*_optional_types("mcp.client.auth", "OAuthFlowError", "OAuthTokenError"),
|
|
*_optional_types("mcp.client.auth", "UnauthorizedError"), # older SDKs
|
|
*_optional_types("tools.mcp_oauth", "OAuthNonInteractiveError"), *http_types)
|
|
_AUTH_ERROR_TYPES = (auth_types, http_types)
|
|
return _AUTH_ERROR_TYPES
|
|
|
|
|
|
def _is_auth_error(exc: BaseException) -> bool:
|
|
"""True if ``exc`` indicates an MCP OAuth failure; ``HTTPStatusError`` counts only with status 401."""
|
|
auth_types, http_types = _get_auth_error_types()
|
|
if not isinstance(exc, auth_types):
|
|
return False
|
|
return getattr(exc.response, "status_code", None) == 401 if isinstance(exc, http_types) else True
|
|
|
|
|
|
# Lower-cased substrings meaning the transport session expired / was GC'd (OAuth token still valid).
|
|
# Substrings (lower-cased match) that indicate the MCP server rejected the request because its server-side
|
|
# transport session expired / was garbage-collected. See #13383.
|
|
_SESSION_EXPIRED_MARKERS: tuple = (
|
|
"invalid or expired session", "expired session", "session expired", "session not found",
|
|
"unknown session", "session terminated", "closedresourceerror", "closed resource",
|
|
"transport is closed", "connection closed", "broken pipe", "end of file")
|
|
|
|
|
|
def _is_session_expired_error(exc: BaseException) -> bool:
|
|
"""True if ``exc`` looks like a transport session expiry (Streamable-HTTP servers GC session state on idle TTL /
|
|
restart / pod rotation while the OAuth token stays valid) — the fix is a transport reconnect, not an OAuth
|
|
refresh. Every node ``_iter_exception_nodes`` reaches is inspected so an InterruptedError anywhere overrides
|
|
transport markers; the chain walk matters because SDK wrappers raise a generic RuntimeError *from* a
|
|
message-less ClosedResourceError."""
|
|
# AnyIO stream exceptions are often message-less, so type checks complement marker matching.
|
|
transport_error_types = tuple(_optional_types("anyio", "BrokenResourceError", "ClosedResourceError", "EndOfStream"))
|
|
found = False
|
|
for current in _iter_exception_nodes(exc):
|
|
if isinstance(current, InterruptedError):
|
|
return False
|
|
# Messages vary across SDK versions/servers: a narrow allow-list of stable substrings avoids false positives.
|
|
msg = str(current).lower()
|
|
found = found or isinstance(current, transport_error_types) or any(m in msg for m in _SESSION_EXPIRED_MARKERS)
|
|
return found
|