modelcontextprotocol / modelcontextprotocol/typescript-sdk
closeSSEStream() breaks request-scoped SSE resumability for terminal responses
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
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 #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 #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:
- #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 - #943, #1186, and #2098 are adjacent resumability/reconnection issues, but not this request-scoped replay bug
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/server/src/server/streamableHttp.ts by reading closeSSEStream() and the request-scoped path in send(). Then run the relevant tests in packages/middleware/node/test/streamableHttp.test.ts and extend coverage for disconnecting a POST SSE stream, completing the tool, and reconnecting with Last-Event-ID; done means the terminal JSON-RPC response is replayed without a missing-connection error.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend, testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100