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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user