You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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
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.
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