Skip to content

Commit d10c315

Browse files
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 (caddyserver/caddy#4247), referenced in the Go issue tracker (golang/go#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: + // golang/go#31125 + // caddyserver/caddy#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>
1 parent 438cd43 commit d10c315

2 files changed

Lines changed: 112 additions & 0 deletions

File tree

mcp/streamable.go

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

mcp/streamable_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2904,3 +2904,99 @@ func TestStreamableOriginProtection(t *testing.T) {
29042904
})
29052905
}
29062906
}
2907+
2908+
// TestStandaloneSSEEmitsCommentForHTTP2Flush is a regression test for the
2909+
// HTTP/2 reverse-proxy header-buffering bug. The standalone SSE GET stream
2910+
// must emit at least one body byte (an SSE comment) immediately after the
2911+
// response headers. Without a DATA frame, HTTP/2 reverse proxies (Envoy,
2912+
// Caddy, net/http/httputil, etc.) buffer the HEADERS frame indefinitely
2913+
// because they have nothing to coalesce it with — calling Flush() alone is
2914+
// not sufficient.
2915+
//
2916+
// SSE explicitly defines lines starting with ":" as comments that clients
2917+
// ignore, so this is behavior-preserving for the SSE protocol while
2918+
// producing the DATA frame that HTTP/2 proxies need.
2919+
func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) {
2920+
server := NewServer(testImpl, nil)
2921+
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)
2922+
httpServer := httptest.NewServer(mustNotPanic(t, handler))
2923+
defer httpServer.Close()
2924+
2925+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
2926+
defer cancel()
2927+
2928+
// Initialize a session so we have a valid Mcp-Session-Id to use on the
2929+
// standalone SSE GET.
2930+
initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
2931+
initReq, err := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, initBody)
2932+
if err != nil {
2933+
t.Fatal(err)
2934+
}
2935+
initReq.Header.Set("Content-Type", "application/json")
2936+
initReq.Header.Set("Accept", "application/json, text/event-stream")
2937+
initResp, err := http.DefaultClient.Do(initReq)
2938+
if err != nil {
2939+
t.Fatal(err)
2940+
}
2941+
io.Copy(io.Discard, initResp.Body)
2942+
initResp.Body.Close()
2943+
sessionID := initResp.Header.Get(sessionIDHeader)
2944+
if sessionID == "" {
2945+
t.Fatalf("initialize response missing %s header", sessionIDHeader)
2946+
}
2947+
2948+
// Open the standalone SSE stream.
2949+
getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpServer.URL, nil)
2950+
if err != nil {
2951+
t.Fatal(err)
2952+
}
2953+
getReq.Header.Set("Accept", "text/event-stream")
2954+
getReq.Header.Set(sessionIDHeader, sessionID)
2955+
2956+
resp, err := http.DefaultClient.Do(getReq)
2957+
if err != nil {
2958+
t.Fatalf("GET standalone SSE: %v", err)
2959+
}
2960+
defer resp.Body.Close()
2961+
2962+
if resp.StatusCode != http.StatusOK {
2963+
t.Fatalf("status = %d; want 200", resp.StatusCode)
2964+
}
2965+
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") {
2966+
t.Fatalf("Content-Type = %q; want text/event-stream", got)
2967+
}
2968+
2969+
// Read the first chunk. With the fix, an SSE comment (": ok\n\n") is
2970+
// written immediately. Without the fix, the body has no bytes to read
2971+
// until the server pushes an event, which won't happen in this test.
2972+
type readResult struct {
2973+
n int
2974+
err error
2975+
buf []byte
2976+
}
2977+
ch := make(chan readResult, 1)
2978+
go func() {
2979+
buf := make([]byte, 64)
2980+
n, err := resp.Body.Read(buf)
2981+
ch <- readResult{n: n, err: err, buf: buf}
2982+
}()
2983+
2984+
select {
2985+
case r := <-ch:
2986+
if r.err != nil && r.err != io.EOF {
2987+
t.Fatalf("reading first SSE chunk: %v", r.err)
2988+
}
2989+
if r.n == 0 {
2990+
t.Fatal("first SSE chunk was empty; expected an SSE comment to flush HTTP/2 proxy HEADERS frame")
2991+
}
2992+
got := string(r.buf[:r.n])
2993+
// SSE spec: lines starting with ":" are comments, ignored by clients.
2994+
// We don't pin the exact comment text; just require the first byte to
2995+
// be the SSE comment marker so HTTP/2 proxies have a DATA frame.
2996+
if !strings.HasPrefix(got, ":") {
2997+
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, ":")
2998+
}
2999+
case <-time.After(2 * time.Second):
3000+
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")
3001+
}
3002+
}

0 commit comments

Comments
 (0)