Skip to content

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

Description

@yhxlele

Is your feature request related to a problem? Please describe.

The 2026-07-28 Streamable HTTP spec ("Receiving Messages") says:

For long-lived streams — in particular the subscriptions/listen response stream — servers are encouraged to periodically emit an SSE comment line (a line beginning with a colon, e.g. :\r\n) as a keep-alive. This keeps the connection from being closed by intermediaries or client idle timeouts during quiet periods when no notifications are flowing. Per the SSE specification, any line beginning with a colon is a comment that carries no event data; clients must ignore such lines and must not treat them as malformed input.

go-sdk cannot do this. The stream's http.ResponseWriter is private to stream, and all writes go through deliverLocked under stream.mu, so a server author has no safe way to write a comment.

Without it, a quiet subscriptions/listen stream is closed by any idle-timeout proxy. The server just sees a cancelled request and unsubscribes. The client's Subscribe already returned, the stream ends without error, and resourceSubs[uri] stays set (mcp/client.go:1393), so even calling Subscribe again is a no-op. The application thinks it is subscribed and never hears anything again.

Nothing else in 2026-07-28 covers this: ping is removed from the protocol, Last-Event-ID resumption is removed, and stateless servers cannot send requests (mcp/streamable.go:1798). The only thing a server can write to the stream today is a notification, so servers end up sending fake notifications/resources/updated as a heartbeat. That makes every client re-read an unchanged resource and runs into #1227.

Describe the solution you'd like

// StreamKeepAlive, if non-zero, writes an SSE comment (":\n\n") and
// flushes on any SSE stream that has been idle for this duration, as
// the 2026-07-28 Streamable HTTP spec encourages for long-lived streams.
// Zero (the default) disables it.
StreamKeepAlive time.Duration
  • Per stream, timer reset on every write.
  • SSE streams only, after headers are committed (composes with mcp: keep a long-running POST stream visibly alive #1197's delayed first flush).
  • Written under stream.mu so it never interleaves with an event.
  • A failed write is a disconnect: release the stream and cancel the request context.
  • No client change needed: scanEvents already ignores comment lines, and the SDK already writes : ok on the GET stream (Streamable HTTP GET requests hang #410).

Describe alternatives you've considered

  • Fake resources/updated heartbeat: works, wrong layer (see above).
  • Client re-subscribe on stream end: worth having too (the spec says the client MAY reconnect on an abrupt close, and resourceSubs should be cleared when the stream ends), but it does not prevent the drop or the notifications lost in between.
  • Exposing the ResponseWriter: every server behind a proxy needs the same thing, and the transport already owns the lock and the write path.

Additional context

The same spec section says servers SHOULD send X-Accel-Buffering: no on SSE responses; acquireStream does not. Easy to add at the same time.

Observed on v1.7.0; the code is the same on current main.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions