Skip to content

Streamable HTTP standalone SSE stream hangs behind HTTP/2 reverse proxies — Flush() alone is insufficient without a DATA frame #937

@jchangx

Description

@jchangx

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+

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