fix(mcp): widen the SSE fallback trigger and harden its guards (salvage #53764)

Relocated onto the decomposed module layout and hardened:

- Trigger covers the rejection CLASS, not just literal 400: SSE-only
  servers' load balancers answer the chunked Streamable HTTP initialize
  POST with 400/405/406/411, and the mcp>=2.0 SDK surfaces many such
  rejections as an opaque -32603 'Server returned an error response'
  (error class per #104363 by @RohithPariki). Timeouts and 5xx never
  trigger the fallback: they are not transport mismatches.
- Reconnect exclusion via _ever_connected instead of _ready: run()
  clears _ready before re-entering the transport, so the original guard
  also fired on reconnects after a proven session.
- Successful fallback latches _sse_fallback so reconnects go straight
  to SSE, and logs a warning suggesting the user pin transport: sse.
- Both transports failing raises a ConnectionError naming both errors
  and suggesting transport: sse / checking the URL.
- No fallback with strict_redirect_headers (SSE cannot enforce that
  boundary) or when transport is explicitly configured.
- Tests trimmed to 3 invariant contracts (proven red on base): fallback
  connects + latches; no fallback on reconnect/timeout/5xx; both-fail
  error is actionable.

The extracted SSE path reuses _sse_transport/_serve_transport from main,
preserving the bounded handshake timeout and reconnect-retry semantics.

Fixes #53676
This commit is contained in:
Teknium
2026-09-09 02:30:49 -07:00
parent b2465f1608
commit a565e2d493
4 changed files with 115 additions and 367 deletions
+19
View File
@@ -62,6 +62,25 @@ class NonMcpEndpointError(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()
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