Skip to content

1.x: streamable_http client leaves a request unresolved when the SSE response stream ends without a response (fixed on 2.x by #3047 — backport request for v1.x) #3441

Open

Description

@godwinbw-egm

Summary

On the 1.x line (v1.29.0, v1.29.1, and the v1.x branch as of 92120b4b), the Streamable HTTP client leaves a pending request unresolved forever when the text/event-stream response for that request ends without a response event. The caller learns of the disconnect only when its own deadline fires, so a dead server is indistinguishable from a slow one — and it is reported as a timeout, not a disconnect.

This is already fixed on 2.x: #3047 (merged 2026-07-07, shipped in v2.0.0) added StreamableHTTPTransport._resolve_abandoned_request(...) and calls it at exactly the two sites below with "SSE stream ended without a response" and "SSE stream ended and reconnection attempts were exhausted". The v1.x branch does not carry it (grep -c _resolve_abandoned_request src/mcp/client/streamable_http.py → 0 at 92120b4b), and neither did the v1.29.1 release (2026-08-24).

Ask: backport the _resolve_abandoned_request resolution from #3047 onto v1.x and cut a 1.29.x/1.30 release. Consumers pinned to mcp<2 (e.g. IBM/mcp-context-forge, mcp>=1.28.1,<2 at v1.0.8/v1.0.9/main) cannot pick the fix up from 2.x.

Mechanism (src/mcp/client/streamable_http.py @ v1.29.0 == v1.29.1, sha256 8eb01c3d…)

  1. _handle_sse_response (:397-435): a completed response returns inside the try. When the server dies mid-stream, EventSource.aiter_sse() raises httpx.RemoteProtocolError("peer closed connection without sending complete message body (incomplete chunked read)"), which except Exception as e: # pragma: no cover (:429-430) logs at DEBUG (SSE stream ended: …). The tail (:432-435) reconnects only if last_event_id is not None; a server that sends no event ids (FastMCP's default, no event store) makes the coroutine return without writing anything to ctx.read_stream_writer. ClientSession.send_request therefore waits forever on its response stream.
  2. _handle_reconnection (:446-448) gives up after MAX_RECONNECTION_ATTEMPTS with a bare return — the same unresolved-request hang for servers that do send ids.

The SDK already has the channel to resolve this: _send_session_terminated_error (:510-522) writes a JSONRPCError for a request id to read_stream_writer, and _receive_loop routes it to the pending request, where send_request raises McpError.

Reproduction (measured 2026-09-04, mcp 1.29.0, httpx 0.28.1, anyio 4.14.2, Python 3.12.13)

Server: a FastMCP 3.x Streamable HTTP server with a tool slow(seconds) that sleeps. Client: streamablehttp_client + ClientSession, initialize(), then

with anyio.fail_after(5):
    await session.call_tool("slow", {"seconds": 4})

and SIGKILL the server ≈1.0 s after the call starts (response headers arrive at ~0.002 s; no event before the kill).

Leaf exception elapsed
v1.29.0 as shipped builtins.TimeoutError (str='') — the caller's own fail_after 5.008 s
v1.29.0 + _resolve_abandoned_request backported in memory (same two sites, same messages, code=CONNECTION_CLOSED) mcp.shared.exceptions.McpError: SSE stream ended without a response (httpx.RemoteProtocolError: peer closed connection without sending complete message body (incomplete chunked read)) 1.06–1.14 s

The SDK's own debug log confirms the swallow: SSE stream ended: peer closed connection without sending complete message body (incomplete chunked read) at +1.11 s, then silence until the caller's deadline.

Related

We are carrying a build-time backport of the two-site resolution in our ContextForge image until a 1.x release ships it; happy to open the backport PR against v1.x if maintainers want it.

Activity

  1. added
    v1Affects the v1.x maintenance line
    on Sep 4, 2026
  2. Ben-Home commented on Sep 4, 2026

    @Ben-Home

    The reproduction is excellent - the fail_after(5) vs 1.06-1.14s backport timing table makes the case for the v1.x backport on its own. Supporting the backport PR, with two scope notes worth folding into it:

    1. Resolve every in-flight request, not just the abandoned one. Streamable HTTP multiplexes concurrent requests over a single SSE stream (the v1 client sends them on one connection and correlates via _pending). When the server dies mid-stream, every outstanding request is stranded, not only the one whose response was pending at the moment of the error. The 2.x _resolve_abandoned_request handles the single-request case; a v1.x backport should iterate the pending table and write a CONNECTION_CLOSED JSONRPCError for each id whose response never arrived, using the same _send_session_terminated_error channel you identified at :510-522. Otherwise a client with two in-flight calls (e.g. a tool call plus a progress/notification exchange) still hangs on the second one after the first is resolved.

    2. Error class matters for caller retry semantics. Your table shows the caller currently sees bare TimeoutError (own deadline) vs McpError CONNECTION_CLOSED after the backport. That distinction is not cosmetic: a timeout is ambiguous (the server may have executed the tool and only the response was lost), while a stream-ended-before-response is a transport-level disconnect where the request may or may not have executed. Callers should treat CONNECTION_CLOSED as retryable but idempotency-checked, and should not blanket-retry on it the way they might on a pre-send failure. Worth stating that in the McpError code docs so 1.x consumers build the right policy on top.

    Also note the no-event-id case from your mechanism step 1 is the more common one in the wild (FastMCP default, no event store), so the backport's primary path is the plain stream-end resolution, with the reconnection-exhausted path secondary - your two-site list already has that ordering right.

    For consumers pinned to mcp<2 who cannot wait for a 1.29.x cut: the build-time backport you are carrying in ContextForge is the pragmatic interim, and the alternative mitigation until then is a client-side watchdog that fails any request whose response has not arrived within N seconds of the SSE stream terminating (your measurement says the stream-end signal arrives ~1s after the kill, so a short post-EOF deadline converts the hang into a fast, classifiable error without touching the SDK). We hit the same timeout-vs-disconnect ambiguity building connector timeouts across 30+ APIs at corpusiq.io - the fix is always to resolve at the transport boundary with a distinct error rather than letting the caller's deadline be the only witness.

  3. zgj-ssslab commented on Sep 5, 2026

    @zgj-ssslab

    Hi! I've opened PR #3444 backporting the abandoned-request resolution from 2.x (#3047) to the v1.x StreamableHTTPTransport, with regression tests for all three unresolved-request paths. The auto-close bot closed the PR because this issue isn't assigned to me yet — could a maintainer assign #3441 so the PR can be tracked and reopen? Happy to adjust anything on the backport.

  4. added 2 commits that reference this issue on Sep 7, 2026
    7af2797
    7c743d3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance line

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions