mcp: add StreamableHTTPOptions.StreamKeepAlive for periodic SSE comments on idle streams - #1232
mcp: add StreamableHTTPOptions.StreamKeepAlive for periodic SSE comments on idle streams#1232yhxlele wants to merge 1 commit into
Conversation
…nts on idle streams
|
Verified end to end against a real deployment rather than only
So the comment resets a real proxy's read timer, the idle-reset spacing is exact (each comment lands |
The 2026-07-28 Streamable HTTP spec encourages servers to periodically emit an SSE comment line on long-lived streams, in particular the
subscriptions/listenresponse, so that idle-timeout intermediaries do not sever them. The SDK had no way to do this: the stream'sResponseWriteris private and every write goes throughdeliverLockedunderstream.mu. Servers behind a proxy ended up sending fakenotifications/resources/updatedas a heartbeat instead.This adds
StreamableHTTPOptions.StreamKeepAlive time.Duration. When non-zero, every SSE response stream gets a goroutine that writes: keepalive\n\nand flushes whenever the stream has carried no bytes for that duration. Zero (the default) keeps today's behavior.Semantics:
stream.lastWriteis stamped by every write path (deliverLocked, the: okand replay writes inacquireStream, the priming event inservePOST); the goroutine sleeps untillastWrite + interval, so a busy stream gets no comments and a quiet one gets exactly one per interval.>= 2026-07-28write nothing before their first event.deliverLockedmay still need to set a 400/404 status for a SEP-2575 error, which requires uncommitted headers, so the goroutine parks on acommittedchannel that the first write closes. Forsubscriptions/listenthe acknowledgment arrives within milliseconds, so this costs nothing there; a long silenttools/callstays 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.stream.mu, so a comment can never interleave with an event.donechannel:hangResponsereturns, 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: nois 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/callgets no comment before its response; a legacytools/calldoes; 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