Repository navigation
Description
Summary
WebStandardStreamableHTTPServerTransport.closeSSEStream() is intended to support SEP-1699-style "disconnect now, keep processing, replay missed events after client reconnects" behavior for request-scoped POST SSE responses.
On current main, request-scoped replay breaks if the terminal response is produced while the SSE stream is intentionally disconnected:
closeSSEStream(requestId)removes the active stream/controller from_streamMappingbut keeps_requestToStreamMapping.- Later,
send()looks up thestreamId, but only stores request-scoped events insideif (!this._enableJsonResponse && stream?.controller && stream?.encoder). - If the handler finishes while the controller is gone, the result/error is not stored in
eventStore. - Once all responses for that request are ready,
send()throwsNo connection established for request ID: ...becausestreamis missing.
That means the exact "server closes SSE, client reconnects, final result is replayed" flow is not reliable for request-scoped POST SSE streams.
Why this looks like a bug, not intentional
The standalone GET SSE path already does the opposite:
- it stores events even while disconnected
- if no active controller exists, it returns without throwing so replay can deliver the missed events later
So the current request-scoped path is asymmetric with the standalone SSE resumability path.
Also, the conformance/example server explicitly advertises this flow in test/conformance/src/everythingServer.ts via the test_reconnection tool.
Repro
- Configure
WebStandardStreamableHTTPServerTransportwith aneventStore. - Use protocol version
2025-11-25or newer socloseSSEStream()is enabled. - Register a tool that:
- calls
ctx.http?.closeSSE?.() - continues working after the stream closes
- returns its final
CallToolResultwhile the client is reconnecting
- calls
- Read the priming event from the original POST SSE response.
- Let the server close that POST SSE stream.
- Reconnect with
Last-Event-ID.
Expected:
- the terminal JSON-RPC response is replayed after reconnect
Actual:
- the terminal response is not replayable for request-scoped SSE
- when all responses become ready,
send()can throwNo connection established for request ID: ...
History
I checked the git history locally before filing this.
- The feature was introduced on November 25, 2025 in commit
3c50d07e/ PR feat: implement SEP-1699 SSE polling via server-side disconnect #1129 (feat: implement SEP-1699 SSE polling via server-side disconnect). - In that original implementation, request-scoped
send()still calledeventStore.storeEvent(streamId, message)even after disconnect, but it still threw once all responses were ready and no active response existed. - On January 16, 2026, commit
f495077e/ PR [v2] Decoupleserverfromexpressandhono- http framework-agnostic MCP server #1326 rewrote the transport for the v2 package split. In the current implementation, request-scopedstoreEvent()now only runs whilestream?.controllerexists, which regressed replay further by losing the final response entirely during the reconnect window.
Current affected code
packages/server/src/server/streamableHttp.ts
closeSSEStream()send()
There is also a test gap: packages/middleware/node/test/streamableHttp.test.ts currently verifies that ctx.http?.closeSSE?.() closes the POST SSE stream, but it does not assert that the terminal response is replayed after reconnect.
Suggested fix
Treat request-scoped SSE like the standalone GET SSE path:
- if
streamIdexists andeventStoreis configured, always store request-scoped events bystreamId, even when no controller is attached - if the controller is missing, skip immediate write but do not throw
- only require an active controller for immediate delivery, not for replay persistence
A regression test could:
- configure
eventStore+ protocol2025-11-25 - close the POST SSE stream via
ctx.http?.closeSSE?.() - complete the tool while disconnected
- reconnect with
Last-Event-ID - assert that the final JSON-RPC result is replayed
Related but not duplicate
I did not find an exact duplicate while searching issues/PRs.
Related items:
- invoke asyn method in mcp tool through StreamableHttpTransport cause early return from tool call #778 reports the same
No connection established for request IDsymptom from a generic async tool call, but this issue is specifically about the intentionalcloseSSEStream()resumability path - The bug in InMemoryEventStore break SSE stream resumability #943, Zombie Task Collision in StreamableHTTPServerTransport #1186, and StreamableHTTPClientTransport: 2-retry SSE reconnect ceiling + silent-success after exhaustion #2098 are adjacent resumability/reconnection issues, but not this request-scoped replay bug
Activity
scosemicolon commentedon May 26, 2026
One test detail that may make this easier to pin down: assert both persistence and non-throwing behavior separately.
For this path I would expect the regression test to fail if either of these happens after
ctx.http?.closeSSE?.():send()throws only because the active controller is missing, even though replay storage is availableThat separates the reconnect replay bug from the immediate delivery path. It also keeps the intended invariant clear: request-scoped POST SSE should behave like standalone GET SSE once the stream id is known and
eventStoreexists.felixweinberger commentedon Oct 5, 2026
Fixed by #2342, released in 2.0.0: a response produced after
closeSSEStream()is stored and replayed when the client reconnects withLast-Event-ID. v1 has the same fix since 1.30.0 (#2547). Closing.