Repository navigation
Resume SSE streams from Last-Event-ID in the Streamable HTTP transport - #681
Open
koic wants to merge 1 commit into
Conversation
## Motivation and Context MCP 2025-11-25 (SEP-1699) lets a client whose SSE connection dropped reconnect with a GET carrying `Last-Event-ID` and receive what it missed, and lets a server close a connection on purpose during a long operation so the client polls instead of holding it open. The TypeScript, Python, Go, and Rust SDKs implement it with an event store on the server transport, a priming event carrying `retry`, and replay on reconnect. The Ruby SDK had the client side only, with a TODO in `send_request`: the referee's `server-sse-polling` scenario warned about the missing priming event and `retry` field and could not test the resume at all. `StreamableHTTPTransport` now takes an `event_store:`, with `retry_interval:` in milliseconds for the `retry` field. With one, every event of a session's GET stream and of each request's response stream is stored under the id the store assigns and written with it, each stream opens with a priming event, and a GET with `Last-Event-ID` replays what the client missed from the stream the event belongs to. When that request is still running, the new connection takes the place of the one that carried the stream, so the handler's remaining messages reach the client; the stream of a request already answered is closed once replayed, as the spec asks after the response. `server_context.close_sse_stream` closes the connection of the current request without ending it, the polling pattern the spec describes, and `close_standalone_sse_stream` does the same for the session's GET stream. `EventStore` documents the two methods a store implements; the SDK ships no store of its own. `examples/in_memory_event_store.rb` is one for a single process, bounded by events per stream, by streams, and by bytes, to copy into an application, as the reference SDKs keep theirs among their examples. Points where the design goes beyond the reference SDKs, and why: - Replay is bound to the session. Stream ids carry the session id, and a `Last-Event-ID` of another session is refused the same way as an unknown one: HTTP 400, nothing replayed, and the value not echoed. The TypeScript and Python SDKs rely on their event ids being unguessable; the check is explicit here because one store serves every session of the transport. - The priming event and the close hooks apply only to sessions that negotiated 2025-11-25 or later, as in the TypeScript and Python SDKs, since earlier clients do not parse an event with empty data. Their other events still carry ids. - A message is stored and its stream chosen under a per-session lock that a resume holds across each replay pass and the attachment of the new connection, so a message is either replayed or written to the attached connection, never both or neither. The lock is never held across a stream write, keeping the transport's rule that a stalled reader stalls nobody else. - A store that fails is reported and the event is written without an id, so an outage of the backend degrades resumability rather than failing requests; an id holding a line break is refused the same way, since it would end the SSE frame early. - Events of a session's GET stream are stored only once the client has opened that stream, which is when it has a cursor; a broadcast then reaches a session whose GET connection is closed at the moment through the store. `event_store:` is refused in stateless mode, which has no sessions to resume. The modern lifecycle (2026-07-28) has no resumable streams, as its spec says, and is untouched. - A GET stream whose write fails is detached from the session instead of ending it when a store is configured, since the client resumes the stream from its last event id; without a store the session is torn down as before. - `close_sse_stream` and `close_standalone_sse_stream` close a connection only once its priming event was stored and sent, so the client always holds a cursor to come back with; when the store failed it, they report `false` and the request is answered on the connection it has. - A resume whose replay passes keep finding events, since events keep being stored while each batch is written, is closed after its sixteenth batch instead of attached, and the client reconnects from its new cursor after `retry`; no connection replays without end, and the lock is still never held across a write. - The in-memory store marks each life of a stream with a random nonce in its event ids, so the id a client holds from a stream that was dropped and written to again matches nothing in the new one and a stale `Last-Event-ID` is refused rather than matched to a newer event; nothing in an id counts other streams' events. The conformance server configures the store with a 100 ms `retry`, and its `test_reconnection` tool closes its stream mid-call, as the TypeScript and Python fixtures do. The server runner now starts the referee with `--unhandled-rejections=warn`: on Node 26, `fetch` rejects a promise without a reason when a response body is cancelled on a pooled connection while the server's next frame is still in flight, which the `server-sse-multiple-streams` scenario does right after reading a stream's priming event, and that rejection ended the referee process mid-suite although it reports nothing. Node 22 and 24, the version CI runs, do not reject, and the referee's checks decide the result as before. A message the transport pushes on its own, a notification or a server-to-client request, is written under `stream_write_timeout:` as before, with its event id. With an event store a stream cut at that deadline is one more disconnected stream: the message is already stored, and the client receives it when it resumes. A failed write to a session's stream is reported as "Failed to send message", since requests travel that path as well as notifications; the `subscriptions/listen` paths keep "Failed to send notification". ## How Has This Been Tested? test/mcp/server/transports/streamable_http_resumability_test.rb, test/examples/in_memory_event_store_test.rb, and test/conformance/server_runner_test.rb. Before the change, `npx @modelcontextprotocol/conformance@alpha server --scenario server-sse-polling` warned on `server-sse-priming-event` and `server-sse-retry-field` and reported `server-sse-disconnect-resume` as untestable; it now passes 3/3 at the default and the 2025-11-25 wire. ## Breaking Changes None. Without `event_store:` the transport writes the same frames as before.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
MCP 2025-11-25 (SEP-1699) lets a client whose SSE connection dropped reconnect with a GET carrying
Last-Event-IDand receive what it missed, and lets a server close a connection on purpose during a long operation so the client polls instead of holding it open. The TypeScript, Python, Go, and Rust SDKs implement it with an event store on the server transport, a priming event carryingretry, and replay on reconnect. The Ruby SDK had the client side only, with a TODO insend_request: the referee'sserver-sse-pollingscenario warned about the missing priming event andretryfield and could not test the resume at all.StreamableHTTPTransportnow takes anevent_store:, withretry_interval:in milliseconds for theretryfield. With one, every event of a session's GET stream and of each request's response stream is stored under the id the store assigns and written with it, each stream opens with a priming event, and a GET withLast-Event-IDreplays what the client missed from the stream the event belongs to. When that request is still running, the new connection takes the place of the one that carried the stream, so the handler's remaining messages reach the client; the stream of a request already answered is closed once replayed, as the spec asks after the response.server_context.close_sse_streamcloses the connection of the current request without ending it, the polling pattern the spec describes, andclose_standalone_sse_streamdoes the same for the session's GET stream.EventStoredocuments the two methods a store implements; the SDK ships no store of its own.examples/in_memory_event_store.rbis one for a single process, bounded by events per stream, by streams, and by bytes, to copy into an application, as the reference SDKs keep theirs among their examples.Points where the design goes beyond the reference SDKs, and why:
Last-Event-IDof another session is refused the same way as an unknown one: HTTP 400, nothing replayed, and the value not echoed. The TypeScript and Python SDKs rely on their event ids being unguessable; the check is explicit here because one store serves every session of the transport.event_store:is refused in stateless mode, which has no sessions to resume. The modern lifecycle (2026-07-28) has no resumable streams, as its spec says, and is untouched.close_sse_streamandclose_standalone_sse_streamclose a connection only once its priming event was stored and sent, so the client always holds a cursor to come back with; when the store failed it, they reportfalseand the request is answered on the connection it has.retry; no connection replays without end, and the lock is still never held across a write.Last-Event-IDis refused rather than matched to a newer event; nothing in an id counts other streams' events.The conformance server configures the store with a 100 ms
retry, and itstest_reconnectiontool closes its stream mid-call, as the TypeScript and Python fixtures do. The server runner now starts the referee with--unhandled-rejections=warn: on Node 26,fetchrejects a promise without a reason when a response body is cancelled on a pooled connection while the server's next frame is still in flight, which theserver-sse-multiple-streamsscenario does right after reading a stream's priming event, and that rejection ended the referee process mid-suite although it reports nothing. Node 22 and 24, the version CI runs, do not reject, and the referee's checks decide the result as before.A message the transport pushes on its own, a notification or a server-to-client request, is written under
stream_write_timeout:as before, with its event id. With an event store a stream cut at that deadline is one more disconnected stream: the message is already stored, and the client receives it when it resumes. A failed write to a session's stream is reported as "Failed to send message", since requests travel that path as well as notifications; thesubscriptions/listenpaths keep "Failed to send notification".How Has This Been Tested?
test/mcp/server/transports/streamable_http_resumability_test.rb, test/examples/in_memory_event_store_test.rb, and test/conformance/server_runner_test.rb. Before the change,
npx @modelcontextprotocol/conformance@alpha server --scenario server-sse-pollingwarned onserver-sse-priming-eventandserver-sse-retry-fieldand reportedserver-sse-disconnect-resumeas untestable; it now passes 3/3 at the default and the 2025-11-25 wire.Breaking Changes
None. Without
event_store:the transport writes the same frames as before.Types of changes
Checklist