Skip to content

mcp: an SSE response keeps http.Server.WriteTimeout armed, so a long-lived stream dies before its first notification #1262

Description

@jmrplens

I run a Streamable HTTP MCP server behind nginx and I audited it against the transport specification. Three separate things have to hold for a long-lived SSE response to survive a real deployment, and the SDK provides none of them. Two are already open: #1155 with #1197 for the headers a streamed POST never commits, and #1229 with #1232 for the keep-alive comment. This is the third, which neither covers and which defeats both of them on any server that sets http.Server.WriteTimeout. I have reproduced the other two against current main as well, and I answer the two questions #1232 left open for review, because the workaround I carry has had to cover all three. If you would rather have this on those threads than in a new issue, say so and I will move it there.

What the specification asks for

The 2026-07-28 Streamable HTTP binding, under Receiving Messages, encourages a periodic SSE comment line on long-lived streams and names the subscriptions/listen response stream in particular:

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.

The same section adds a SHOULD that belongs to the same problem:

When initiating an SSE stream, servers SHOULD include the X-Accel-Buffering: no header in the HTTP response.

The HTML standard, which defines the event stream format, says the same thing about the wire, with an interval:

Legacy proxy servers are known to, in certain cases, drop HTTP connections after a short timeout. To protect against such proxy servers, authors can include a comment line (one starting with a ':' character) every 15 seconds or so.

And for 2025-11-25 and earlier, the transports section asks the server to put something on a POST-initiated stream at once:

The server SHOULD immediately send an SSE event consisting of an event ID and an empty data field in order to prime the client to reconnect (using that event ID as Last-Event-ID).

None of these is a MUST, which is exactly why they get skipped: they are all about the hop between the server and the client, the one thing a conformance test cannot see and a deployment finds out about at sixty seconds.

What the SDK does instead

Citations are against main at 5bc078a.

StreamableHTTPOptions (mcp/streamable.go:128-220) has no keep-alive interval, and the transport makes no periodic write anywhere. The only comment frame the server ever writes is in acquireStream (mcp/streamable.go:1289), gated on s.id == "" (mcp/streamable.go:1360) so that it reaches the standalone GET stream and nothing else, and it is written once, when the stream opens:

w.WriteHeader(http.StatusOK)
fmt.Fprint(w, ": ok\n\n")
rc := http.NewResponseController(w)
// Ignore returned error as flushing is best-effort.
_ = rc.Flush()

That is mcp/streamable.go:1378-1382, added for #410, under a comment explaining that a Flush alone is not enough on HTTP/2 and that a DATA frame is what makes a proxy forward the headers. Nothing repeats it a second later, and no other stream gets it at all.

servePOST (mcp/streamable.go:1415) sets Content-Type: text/event-stream at mcp/streamable.go:1688 and then hangs at mcp/streamable.go:1770 with nothing written. The one thing that could commit those headers early is the priming event at mcp/streamable.go:1736, which is gated on an EventStore being configured and on a protocol version below 2026-07-28, so a default server writes nothing at all until the handler produces a message and writeEvent (mcp/event.go:44) flushes it. That is the case #1155 makes and I am not restating it.

subscriptions/listen is the stream the specification names, and the SDK describes it the same way at mcp/streamable.go:1678-1682: "it has no synchronous result, the response stream stays open until the client cancels". Its acknowledgment (mcp/server.go:1310) commits the headers within milliseconds, so the listen stream is not the header case. It is the idle case, and after that acknowledgment it can be silent for hours.

Then the part nobody has named. Nothing in the module reads or writes a deadline: SetWriteDeadline and WriteTimeout appear in no file, tests included. An http.Server with WriteTimeout set, which is the ordinary slow-write guard and what every hardened deployment has, therefore applies that deadline to a response the transport intends to hold open indefinitely. The deadline does not close the connection by itself, because nothing is being written. It fails the next write. So the stream sits there looking healthy from both ends until the first real notification, which is then lost along with the stream.

Reproduction

Three runs against main at 5bc078a, Go 1.27.0, all through the exported API with no client library, so the timings are the wire and not a client's retry policy.

A tools/call whose handler sleeps for three seconds, on a stateless handler with no EventStore:

  3.01s  response headers: 200 OK text/event-stream
  3.01s  event: message
  3.01s  data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"done"}]}}

The response headers arrive at 3.01s. The server chose text/event-stream before the handler ran and committed nothing, so a client with a first-byte timeout, or a proxy with a read timeout, cuts a healthy call. This is #1155.

A subscriptions/listen on the same server, watching for five seconds after the acknowledgment:

     0s  response headers: 200 OK text/event-stream
     0s  event: message
     0s  data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":...
  5.02s  nothing further was written

Nothing follows, and nothing ever would. nginx defaults proxy_read_timeout to 60s, so this stream has a minute to live on a default deployment. This is #1229.

The third one. Same listen stream, on an http.Server with WriteTimeout: 2 * time.Second, with a real notification produced after the deadline has passed:

func noop(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
	return &mcp.CallToolResult{}, nil, nil
}

func main() {
	server := mcp.NewServer(&mcp.Implementation{Name: "repro", Version: "v1"}, nil)
	mcp.AddTool(server, &mcp.Tool{Name: "noop"}, noop)
	handler := mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return server },
		&mcp.StreamableHTTPOptions{Stateless: true},
	)

	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	// A hardened deployment sets WriteTimeout as a slow-write guard.
	httpServer := &http.Server{Handler: handler, WriteTimeout: 2 * time.Second}
	go httpServer.Serve(ln)
	defer httpServer.Close()

	listen := `{"jsonrpc":"2.0","id":1,"method":"subscriptions/listen","params":{` +
		`"notifications":{"toolsListChanged":true},` +
		`"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",` +
		`"io.modelcontextprotocol/clientInfo":{"name":"repro","version":"v1"},` +
		`"io.modelcontextprotocol/clientCapabilities":{}}}}`
	req, err := http.NewRequest(http.MethodPost, "http://"+ln.Addr().String(), bytes.NewReader([]byte(listen)))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json, text/event-stream")
	req.Header.Set("MCP-Protocol-Version", "2026-07-28")
	req.Header.Set("Mcp-Method", "subscriptions/listen")

	start := time.Now()
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	at := func() string { return time.Since(start).Round(10 * time.Millisecond).String() }
	fmt.Printf("%7s  response headers: %s %s (http.Server.WriteTimeout = 2s)\n",
		at(), resp.Status, resp.Header.Get("Content-Type"))
	go func() {
		scanner := bufio.NewScanner(resp.Body)
		for scanner.Scan() {
			if line := scanner.Text(); line != "" {
				fmt.Printf("%7s  %s\n", at(), line)
			}
		}
		fmt.Printf("%7s  stream ended: scanner err = %v\n", at(), scanner.Err())
	}()

	time.Sleep(4 * time.Second) // past the write deadline
	fmt.Printf("%7s  the write deadline has passed; registering a tool, which notifies the listener\n", at())
	mcp.AddTool(server, &mcp.Tool{Name: "added-later"}, noop)
	time.Sleep(4 * time.Second)
}
     0s  response headers: 200 OK text/event-stream (http.Server.WriteTimeout = 2s)
     0s  event: message
     0s  data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":...
  4.01s  the write deadline has passed; registering a tool, which notifies the listener
  4.02s  stream ended: scanner err = unexpected EOF

With WriteTimeout left at zero and nothing else changed, the same run delivers the notification at 4.03s and the stream stays open, so the deadline is the whole of it:

  4.03s  data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":...

Two consequences worth stating plainly. A subscription on such a server is already dead at WriteTimeout and neither end can tell, which is worse than a torn connection because the client's resourceSubs entry stays set and a second Subscribe is a no-op (mcp/client.go:1393, as #1229 notes). And adding a keep-alive on top makes it louder rather than better: under the semantics #1232 proposes, where a failed write ends the stream, the first comment after the deadline tears the subscription down instead of leaving it silently broken. The keep-alive cannot fix this on its own, and neither can the early header flush.

What a server built on the SDK cannot do today

It cannot write a keep-alive. The stream's http.ResponseWriter is private to stream and every write goes through deliverLocked under stream.mu, so there is no safe place for one byte of my own.

It cannot clear the write deadline either, at least not where it belongs. A middleware can call SetWriteDeadline(time.Time{}) before the handler runs, but only for every route at once, which gives up the slow-write guard on ordinary JSON responses too. Scoping it to SSE means wrapping the ResponseWriter and watching for the response to commit to text/event-stream, because the transport is the only layer that knows when a response became a stream. That is what my server carries: a ResponseWriter wrapper that clears the deadline and sets X-Accel-Buffering: no when the Content-Type commits, and runs a 25 second heartbeat under a mutex it has to share with the SDK's own writes so that a comment cannot land inside an event. 25 seconds because nginx's default proxy_read_timeout is 60, and two frames inside the window is the margin I wanted. It works, and it is about a hundred lines reimplementing a decision that is not mine to make.

The remaining options are worse. Leaving WriteTimeout unset gives up a real guard on every route. Sending a fake notifications/resources/updated as a heartbeat, which is what people do, makes every client re-read a resource that did not change and runs into #1227.

What I propose

Three parts. Only the first needs new exported API, which is why this is an issue and not a pull request.

1. The interval, on StreamableHTTPOptions. #1229 proposed the name StreamKeepAlive and I would keep it rather than invent a competing one:

// StreamKeepAlive is how long a text/event-stream response may carry no bytes
// before the transport writes an SSE comment line and flushes it, so that an
// intermediary's idle timeout does not sever a stream that is merely quiet.
//
// The comment is written under the same lock as every other write to the
// stream, so it can never land inside an event, and a write that fails ends
// the stream the way a failed event does.
//
// If zero, [DefaultStreamKeepAlive] is used. A negative value disables
// keep-alives entirely.
StreamKeepAlive time.Duration
// DefaultStreamKeepAlive is the default value used for
// [StreamableHTTPOptions.StreamKeepAlive] when it is left at zero.
const DefaultStreamKeepAlive = 15 * time.Second

It reads from StreamableHTTPOptions and from nowhere else: resolved once in NewStreamableHTTPHandler (mcp/streamable.go:231) the way MaxRequestBodyBytes already is, and carried onto StreamableServerTransport beside EventStore, so a caller who writes their own handler gets the same behaviour. Nothing per session, nothing from the environment. 15 seconds is the HTML standard's own figure.

2. Clear the write deadline when a response commits to SSE. No new API. At the two places that set the Content-Type, mcp/streamable.go:1357 and mcp/streamable.go:1688:

// Best-effort: a transport that manages its own deadlines (HTTP/2) returns
// an error here, and that is fine.
_ = http.NewResponseController(w).SetWriteDeadline(time.Time{})

X-Accel-Buffering: no belongs on those same two lines, for the SHOULD quoted above. #1232 already adds it; I am naming it here only because the deadline sits in the same three lines.

3. Commit the headers on a streamed POST. No new API either, and #1197 already proposes it with the one constraint that matters: deliverLocked can still need to set a 400 or a 404 for a SEP-2575 protocol error (mcp/streamable.go:1101), which requires uncommitted headers, so the commit has to wait out a short delay or be skipped for streams that can still produce one. I mention it because the three are one behaviour from a deployment's point of view, and fixing any two still leaves a broken stream.

Alternatives I considered

Zero disables, with no default. That is what #1229 proposes and it keeps today's behaviour for a server that never heard of proxies, which is also the server most likely to be cut by one. I prefer zero meaning the default because the failure is silent, because the specification encourages the comment rather than leaving it to taste, and because MaxRequestBodyBytes already establishes exactly this convention in the same struct (mcp/streamable.go:205-208: zero means the default, negative disables). I will take either; this is the question #1232 asked and that is my answer to it.

The bare : rather than : keepalive. The other question on #1232. Both are legal and the SDK's own scanner ignores either. My server writes : keep-alive because it is easy to pick out of a capture, but the specification's example is a bare : and I would take that.

ServerOptions.KeepAlive instead of anything new (mcp/server.go:100). It is a JSON-RPC ping on the session rather than bytes on a stream. It is unavailable from 2026-07-28, which the SDK says itself at mcp/client.go:198; a stateless server is refused outright when it tries to make any request (mcp/streamable.go:1831); and where it does work it lands on whichever stream Write selects (mcp/streamable.go:1876) rather than on the quiet one. The note at mcp/streamable.go:76-78 that clients should ping to keep a session live has the same problem from the other end, and a client cannot ping on a protocol revision that removed ping.

Extending the deadline instead of clearing it. On every write, push it out by twice the keep-alive interval. That keeps a bound on a peer that has stopped reading, which is the thing WriteTimeout was set for, and I would take it happily. What I care about is that the transport decides, because it is the only layer that knows a response became a stream.

Exporting the stream's ResponseWriter. It hands out the framing invariant along with the writer. The transport owns the lock and the write path; a keep-alive is the transport's business.

Leaving all of it to the operator. That is the status quo, and the wrapper above is what it costs. Every server behind a proxy needs the same three things, and each one has to reach into the transport's own decision to get them.

Versions

Observed on v1.7.0 and reproduced on main at 5bc078a, Go 1.27.0, linux/amd64.

I am happy to open the pull request once you say which shape you want: the option with a non-zero default, the option with zero meaning disabled, or the deadline change on its own, which needs no new API and could land ahead of either. If the author of #1232 would rather fold the deadline into that branch, that suits me just as well and I will send it there.

Part of #1257.

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