Skip to content

Resume SSE streams from Last-Event-ID in the Streamable HTTP transport - #681

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:resume_sse_streams_with_last_event_id_in_the_streamable_http_transport
Open

koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:resume_sse_streams_with_last_event_id_in_the_streamable_http_transport

Conversation

@koic

@koic koic commented Nov 12, 2026

Copy link
Copy Markdown
Member

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## 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.

@atesgoral atesgoral left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants