Skip to content

mcp: add StreamableHTTPOptions.StreamKeepAlive for periodic SSE comments on idle streams - #1232

Open
yhxlele wants to merge 1 commit into
modelcontextprotocol:mainfrom
yhxlele:stream-keepalive
Open

mcp: add StreamableHTTPOptions.StreamKeepAlive for periodic SSE comments on idle streams#1232
yhxlele wants to merge 1 commit into
modelcontextprotocol:mainfrom
yhxlele:stream-keepalive

Conversation

@yhxlele

@yhxlele yhxlele commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The 2026-07-28 Streamable HTTP spec encourages servers to periodically emit an SSE comment line on long-lived streams, in particular the subscriptions/listen response, so that idle-timeout intermediaries do not sever them. The SDK had no way to do this: the stream's ResponseWriter is private and every write goes through deliverLocked under stream.mu. Servers behind a proxy ended up sending fake notifications/resources/updated as a heartbeat instead.

This adds StreamableHTTPOptions.StreamKeepAlive time.Duration. When non-zero, every SSE response stream gets a goroutine that writes : keepalive\n\n and flushes whenever the stream has carried no bytes for that duration. Zero (the default) keeps today's behavior.

Semantics:

  • Idle-reset per stream. stream.lastWrite is stamped by every write path (deliverLocked, the : ok and replay writes in acquireStream, the priming event in servePOST); the goroutine sleeps until lastWrite + interval, so a busy stream gets no comments and a quiet one gets exactly one per interval.
  • Streams on protocol >= 2026-07-28 write nothing before their first event. deliverLocked may still need to set a 400/404 status for a SEP-2575 error, which requires uncommitted headers, so the goroutine parks on a committed channel that the first write closes. For subscriptions/listen the acknowledgment arrives within milliseconds, so this costs nothing there; a long silent tools/call stays uncovered until mcp: keep a long-running POST stream visibly alive #1197 commits its headers, after which the two compose. Earlier protocol versions have no status override, so their streams (including resumed GET streams) are kept alive from the start.
  • Writes happen under stream.mu, so a comment can never interleave with an event.
  • A failed write closes the stream's done channel: hangResponse returns, the request context is cancelled, and a listen handler unwinds and unsubscribes. A dead peer is therefore noticed within one interval rather than at the next real notification.
  • X-Accel-Buffering: no is now set on SSE responses, which the same spec section recommends; the header is dropped again on the JSON error-override path.

Tests: a raw listen POST sees the ack and then only comments; a slow modern tools/call gets no comment before its response; a legacy tools/call does; the stateful GET stream is covered; the goroutine semantics (park, idle-reset, failed write closes the stream) are tested directly; keep-alive goroutines end with their streams; and an end-to-end test puts an idle-timeout proxy in front of the server and checks that a quiet subscription dies without the option and survives with it, with the SDK client ignoring the comments.

Open questions for review: whether the default should be non-zero so that servers follow the spec out of the box, and whether the comment should be the bare : from the spec example rather than : keepalive.

Fixes #1229

@yhxlele

yhxlele commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Verified end to end against a real deployment rather than only httptest: a Go server on this branch with StreamKeepAlive: 30 * time.Second, its own periodic notification heartbeat disabled, one raw subscriptions/listen POST observing wire lines, and a go-sdk v1.7.0 client subscribed to the same resource. After a quiet period a database update fires the server's real change notification.

Edge in front of the server Quiet period Wire Delivery after the update
Traefik (no idle timeout) 150s ack, then : keepalive at +30.0s, +60.0s, +90.0s, +120.0s, +150.0s; no other bytes 326ms, stream still open
nginx proxy_read_timeout 45s 100s ack, then : keepalive at +30s, +60s, +90s 279ms, stream still open
nginx proxy_read_timeout 20s (control, shorter than the interval) ack only; nginx closed the stream at +21.5s (unexpected EOF) none: the subscription was gone

So the comment resets a real proxy's read timer, the idle-reset spacing is exact (each comment lands interval after the previous byte, not on a fixed clock), the go-sdk client ignores the comments and keeps working, and without bytes the same proxy drops a quiet listen stream before the first interval. nginx also honoured and stripped X-Accel-Buffering: no (present on the direct connection, absent behind nginx), as expected.

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.

mcp: add StreamableHTTPOptions.StreamKeepAlive — the SSE keep-alive comment the 2026-07-28 spec encourages for subscriptions/listen

1 participant