Summary
The standalone SSE GET stream (the one with empty session ID, s.id == "" in mcp/streamable.go) hangs for the full request-timeout window when served behind an HTTP/2 reverse proxy (Envoy, Caddy, Go's httputil.ReverseProxy, etc.). The fix from #410 / #413 (refactored in #870) handles HTTP/1.1 correctly but does not produce an HTTP/2 DATA frame, which is what proxies need before they'll forward the HEADERS frame to the client.
This is closely related to — and not fully resolved by — issue #410. Filing a fresh issue because the symptoms are the same but the failure mode is HTTP/2-specific and the existing fix gives the impression the bug is closed.
Why Flush() is not enough on HTTP/2
In HTTP/1.1, response headers are text on a TCP stream — Flush() writes them out and proxies forward them immediately. In HTTP/2, headers travel as HEADERS frames and body as DATA frames. Reverse proxies typically batch the two for efficiency: they hold the HEADERS frame until they have a DATA frame to coalesce with. There is no HTTP/2 equivalent of HTTP/1.1's Transfer-Encoding: chunked signal that says "this is a streaming response, send the headers now."
http.NewResponseController(w).Flush() (or w.(http.Flusher).Flush()) only pushes the in-process buffer onto the HTTP/2 stack — the proxy still buffers the HEADERS frame because no DATA frame has been produced yet.
Reproduction
I reproduced this with both curl directly against an HTTP/2 reverse proxy and Go's own httputil.ReverseProxy in HTTP/2 mode.
| Configuration |
TTFB |
Result |
| HTTP/2 + 30s request timeout |
~31s |
Headers arrive only when proxy tears down stream on timeout |
| HTTP/2 + no request timeout |
never |
Headers never arrive |
| HTTP/1.1 + 30s request timeout |
~300ms |
Headers arrive immediately, stream killed at 30s |
| HTTP/1.1 + no request timeout |
~270ms |
Headers arrive immediately, stream lives indefinitely |
| HTTP/2 POST (response includes body) |
~350ms |
Works because there's a DATA frame |
With an SSE-comment workaround applied (writing : ok\n\n after WriteHeader), HTTP/2 TTFB drops to ~1ms.
Real-world impact
I hit this running an MCP server through Envoy. Sessions consistently took ~31 seconds to start, matching the configured requestTimeout: 30s. The hang was deterministic; the SDK's server-side Flush() was visibly being called but the client never saw the headers until the timeout fired. Anyone running an MCP server behind an HTTP/2-aware proxy (which is the common production setup) will likely hit this.
Proposed fix
Write an SSE comment line (which clients ignore per spec — any line starting with :) so that a DATA frame is produced after the headers. That forces HTTP/2 reverse proxies to forward both frames together.
The change is one added line at the existing s.id == "" block in mcp/streamable.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.
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; the change is purely additive.
I'm happy to open a PR with this change plus a regression test that puts a httputil.ReverseProxy in HTTP/2 mode in front of StreamableHTTPHandler and asserts headers arrive within a short deadline. Without the change the test hangs until its own timeout; with the change TTFB is ~1ms.
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.
Prior art / related links
Environment
- Go SDK:
main (verified at current mcp/streamable.go:1061-1068)
- Reverse proxies confirmed: Envoy 1.34, also reproduced with
net/http/httputil
- Go: 1.24+
Summary
The standalone SSE GET stream (the one with empty session ID,
s.id == ""inmcp/streamable.go) hangs for the full request-timeout window when served behind an HTTP/2 reverse proxy (Envoy, Caddy, Go'shttputil.ReverseProxy, etc.). The fix from #410 / #413 (refactored in #870) handles HTTP/1.1 correctly but does not produce an HTTP/2 DATA frame, which is what proxies need before they'll forward the HEADERS frame to the client.This is closely related to — and not fully resolved by — issue #410. Filing a fresh issue because the symptoms are the same but the failure mode is HTTP/2-specific and the existing fix gives the impression the bug is closed.
Why
Flush()is not enough on HTTP/2In HTTP/1.1, response headers are text on a TCP stream —
Flush()writes them out and proxies forward them immediately. In HTTP/2, headers travel as HEADERS frames and body as DATA frames. Reverse proxies typically batch the two for efficiency: they hold the HEADERS frame until they have a DATA frame to coalesce with. There is no HTTP/2 equivalent of HTTP/1.1'sTransfer-Encoding: chunkedsignal that says "this is a streaming response, send the headers now."http.NewResponseController(w).Flush()(orw.(http.Flusher).Flush()) only pushes the in-process buffer onto the HTTP/2 stack — the proxy still buffers the HEADERS frame because no DATA frame has been produced yet.Reproduction
I reproduced this with both
curldirectly against an HTTP/2 reverse proxy and Go's ownhttputil.ReverseProxyin HTTP/2 mode.With an SSE-comment workaround applied (writing
: ok\n\nafterWriteHeader), HTTP/2 TTFB drops to ~1ms.Real-world impact
I hit this running an MCP server through Envoy. Sessions consistently took ~31 seconds to start, matching the configured
requestTimeout: 30s. The hang was deterministic; the SDK's server-sideFlush()was visibly being called but the client never saw the headers until the timeout fired. Anyone running an MCP server behind an HTTP/2-aware proxy (which is the common production setup) will likely hit this.Proposed fix
Write an SSE comment line (which clients ignore per spec — any line starting with
:) so that a DATA frame is produced after the headers. That forces HTTP/2 reverse proxies to forward both frames together.The change is one added line at the existing
s.id == ""block inmcp/streamable.go:fmtis already imported in this file; the change is purely additive.I'm happy to open a PR with this change plus a regression test that puts a
httputil.ReverseProxyin HTTP/2 mode in front ofStreamableHTTPHandlerand asserts headers arrive within a short deadline. Without the change the test hangs until its own timeout; with the change TTFB is ~1ms.Risk
:line is a valid SSE comment and is ignored by every conforming SSE client).Prior art / related links
http.NewResponseControllerrefactor (current state onmain)Environment
main(verified at currentmcp/streamable.go:1061-1068)net/http/httputil