mcp: write SSE comment on standalone stream so HTTP/2 reverse proxies flush HEADERS frame (#938)

Fixes #937.

### What this changes

Adds a single line — an SSE comment (`: ok\n\n`) — after `WriteHeader`
and before `Flush` on the standalone SSE GET stream. SSE comments are
explicitly ignored by clients (any line starting with `:` per the SSE
spec), but they produce an HTTP/2 DATA frame, which is what HTTP/2
reverse proxies need before they will forward the HEADERS frame.

### Why the existing fix isn't sufficient

Issue #410 was closed by #413, which added `WriteHeader(StatusOK)` +
`Flush()` to the same site. #870 refactored the `Flush()` to use
`http.NewResponseController`. Both correctly address HTTP/1.1: `Flush()`
writes the headers as text on the TCP stream and any HTTP/1.1-aware
proxy forwards them.

In HTTP/2, headers and body are separate frame types (HEADERS, DATA).
Reverse proxies batch them for efficiency and won't forward the HEADERS
frame on its own. `Flush()` on the response writer only pushes the
in-process buffer to the HTTP/2 stack; the proxy still holds the HEADERS
frame waiting for DATA. The standalone SSE stream never sends body data
on connect (the whole point — it's the long-poll listener for
server-pushed notifications), so without a deliberate "kick" the headers
never reach the client until the proxy tears down the stream on timeout.

The SSE-comment trick is the established mitigation for SSE servers
behind HTTP/2 proxies. It's used in Caddy
(https://github.com/caddyserver/caddy/issues/4247), referenced in the Go
issue tracker (https://github.com/golang/go/issues/31125), and is
independent of the proxy implementation.

### The diff

```go
 if s.id == "" {
     // Issue #410: the standalone SSE stream is likely not to receive messages
     // for a long time. Ensure that headers are flushed.
+    //
+    // On HTTP/2, headers and body travel as separate frames (HEADERS and
+    // DATA). Reverse proxies (e.g. Envoy, Caddy, net/http/httputil)
+    // commonly buffer the HEADERS frame until they have a DATA frame to
+    // coalesce it with — there is no HTTP/2 equivalent of HTTP/1.1's
+    // Transfer-Encoding: chunked signal that says "this is streaming, send
+    // headers now". Calling Flush() alone is not sufficient: it pushes
+    // the kernel buffer to the proxy, but the proxy still holds the
+    // HEADERS frame.
+    //
+    // Write an SSE comment (lines starting with ":" are ignored by
+    // clients per RFC) so a DATA frame is produced, which forces the
+    // proxy to forward both frames. See:
+    //   https://github.com/golang/go/issues/31125
+    //   https://github.com/caddyserver/caddy/issues/4247
     w.WriteHeader(http.StatusOK)
+    fmt.Fprint(w, ": ok\n\n")
     rc := http.NewResponseController(w)
     // Ignore returned error as flushing is best-effort.
     _ = rc.Flush()
 }
```

`fmt` is already imported in this file; no other dependencies change.

### Test

Added `TestStandaloneSSEEmitsCommentForHTTP2Flush`. It initializes a
streamable session via raw POST, then opens the standalone SSE GET and
asserts the first body byte starts with `:` (the SSE comment marker,
which is what produces the DATA frame HTTP/2 proxies need).

The test catches the bug behaviorally without requiring a full HTTP/2
reverse-proxy setup: it verifies the SDK's contract (emit body bytes
after headers on the standalone SSE stream). HTTP/2-specific behavior is
then a property of any conforming proxy.

I confirmed the test fails on `main` without the patch — it times out at
2s waiting for the first body byte — and passes in <1ms with the patch.
Full streamable test suite still passes.

### Reproduction matrix (from #937)

| Configuration | TTFB | Result |
|---|---|---|
| HTTP/2 + 30s request timeout | ~31s | Headers only on stream tear-down
|
| HTTP/2 + no request timeout | never | Headers never arrive |
| HTTP/1.1 + 30s request timeout | ~300ms | Headers immediate |
| HTTP/1.1 + no request timeout | ~270ms | Headers immediate |
| HTTP/2 POST (response has body) | ~350ms | Works (DATA frame
coalesces) |
| **HTTP/2 + this PR** | ~1ms | Headers immediate |

### Risk

- Behavior-preserving for HTTP/1.1 clients (a leading `:` line is a
valid SSE comment and is ignored by every conforming SSE client).
- No protocol-level concern: SSE explicitly defines comment lines as a
no-op for the consumer.
- The DATA frame adds 7 bytes per standalone SSE connection, sent once
on connect.

Co-authored-by: Maciej Kisiel <mkisiel@google.com>
This commit is contained in:
Justin Chang 2026-05-07 05:28:38 -07:00 • committed by GitHub
commit d10c315008
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 112 additions and 0 deletions

View file

@ -1061,7 +1061,23 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons
if s.id == "" {
// Issue #410: the standalone SSE stream is likely not to receive messages
// for a long time. Ensure that headers are flushed.
//
// On HTTP/2, headers and body travel as separate frames (HEADERS and
// DATA). Reverse proxies (e.g. Envoy, Caddy, net/http/httputil)
// commonly buffer the HEADERS frame until they have a DATA frame to
// coalesce it with — there is no HTTP/2 equivalent of HTTP/1.1's
// Transfer-Encoding: chunked signal that says "this is streaming, send
// headers now". Calling Flush() alone is not sufficient: it pushes
// the kernel buffer to the proxy, but the proxy still holds the
// HEADERS frame.
//
// Write an SSE comment (lines starting with ":" are ignored by
// clients per RFC) so a DATA frame is produced, which forces the
// proxy to forward both frames. See:
// https://github.com/golang/go/issues/31125
// https://github.com/caddyserver/caddy/issues/4247
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, ": ok\n\n")
rc := http.NewResponseController(w)
// Ignore returned error as flushing is best-effort.
_ = rc.Flush()

View file

@ -2904,3 +2904,99 @@ func TestStreamableOriginProtection(t *testing.T) {
})
}
}
// TestStandaloneSSEEmitsCommentForHTTP2Flush is a regression test for the
// HTTP/2 reverse-proxy header-buffering bug. The standalone SSE GET stream
// must emit at least one body byte (an SSE comment) immediately after the
// response headers. Without a DATA frame, HTTP/2 reverse proxies (Envoy,
// Caddy, net/http/httputil, etc.) buffer the HEADERS frame indefinitely
// because they have nothing to coalesce it with — calling Flush() alone is
// not sufficient.
//
// SSE explicitly defines lines starting with ":" as comments that clients
// ignore, so this is behavior-preserving for the SSE protocol while
// producing the DATA frame that HTTP/2 proxies need.
func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) {
server := NewServer(testImpl, nil)
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Initialize a session so we have a valid Mcp-Session-Id to use on the
// standalone SSE GET.
initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
initReq, err := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, initBody)
if err != nil {
t.Fatal(err)
}
initReq.Header.Set("Content-Type", "application/json")
initReq.Header.Set("Accept", "application/json, text/event-stream")
initResp, err := http.DefaultClient.Do(initReq)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, initResp.Body)
initResp.Body.Close()
sessionID := initResp.Header.Get(sessionIDHeader)
if sessionID == "" {
t.Fatalf("initialize response missing %s header", sessionIDHeader)
}
// Open the standalone SSE stream.
getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpServer.URL, nil)
if err != nil {
t.Fatal(err)
}
getReq.Header.Set("Accept", "text/event-stream")
getReq.Header.Set(sessionIDHeader, sessionID)
resp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatalf("GET standalone SSE: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d; want 200", resp.StatusCode)
}
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") {
t.Fatalf("Content-Type = %q; want text/event-stream", got)
}
// Read the first chunk. With the fix, an SSE comment (": ok\n\n") is
// written immediately. Without the fix, the body has no bytes to read
// until the server pushes an event, which won't happen in this test.
type readResult struct {
n int
err error
buf []byte
}
ch := make(chan readResult, 1)
go func() {
buf := make([]byte, 64)
n, err := resp.Body.Read(buf)
ch <- readResult{n: n, err: err, buf: buf}
}()
select {
case r := <-ch:
if r.err != nil && r.err != io.EOF {
t.Fatalf("reading first SSE chunk: %v", r.err)
}
if r.n == 0 {
t.Fatal("first SSE chunk was empty; expected an SSE comment to flush HTTP/2 proxy HEADERS frame")
}
got := string(r.buf[:r.n])
// SSE spec: lines starting with ":" are comments, ignored by clients.
// We don't pin the exact comment text; just require the first byte to
// be the SSE comment marker so HTTP/2 proxies have a DATA frame.
if !strings.HasPrefix(got, ":") {
t.Errorf("first SSE chunk = %q; want it to start with %q (SSE comment marker, so HTTP/2 proxies receive a DATA frame and forward HEADERS)", got, ":")
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for first SSE bytes; the standalone SSE stream must emit a DATA frame immediately so HTTP/2 reverse proxies don't buffer the HEADERS frame")
}
}