Skip to content

Streamable HTTP: POST response writes nothing until the request completes, so long tool calls look like a dead connection #1155

Description

@spkenny455

What did you do?

Called a tool that takes a while to return, over Streamable HTTP, with a client that applies a first-byte or idle timeout.

Minimal reproduction (added as a test in mcp):

// The tool blocks until the test releases it, standing in for a slow call.
release := make(chan struct{})
server := NewServer(testImpl, nil)
AddTool(server, &Tool{Name: "slow"}, func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
    select {
    case <-release:
    case <-ctx.Done():
        return nil, nil, ctx.Err()
    }
    return &CallToolResult{Content: []Content{&TextContent{Text: "done"}}}, nil, nil
})
httpServer := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))

Then initialize, and POST a tools/call for slow with a 5s context while the tool is still blocked.

What did you see?

The POST does not return response headers at all. http.Client.Do never returns while the tool is running:

no response headers within the deadline: Post "http://127.0.0.1:60573": context deadline exceeded

Nothing is written to the response — not the body, and not the headers — until the tool call completes. A client cannot distinguish a tool that is still working from a connection that has died, and any first-byte or idle timeout fires on a healthy in-flight call. Intermediaries that buffer idle responses have the same problem.

What did you expect to see?

The response headers, promptly, as happens today for the standalone GET stream.

acquireStream already does this for s.id == "", added for #410, with a comment explaining that a Flush alone is not enough on HTTP/2 and that an SSE comment is needed to produce a DATA frame. That reasoning is not specific to the standalone stream — a POST carrying a long-running tools/call is arguably the more common case of a stream that stays quiet for a long time.

The spec wording is also parallel for the two paths. For GET: the server "MUST either return Content-Type: text/event-stream in response to this HTTP GET, or else return HTTP 405". For POST carrying a request: the server "MUST either return Content-Type: text/event-stream, to initiate an SSE stream, or Content-Type: application/json". The spec further says the server MAY send requests and notifications on that stream before the response, which presumes the stream is actually established.

What version of the Go MCP SDK are you using?

v1.7.0 (also present on v1.6.1).

What version of Go are you using?

go version go1.26.5 darwin/arm64

Complication worth naming up front

The POST path cannot simply flush at t=0 the way the GET path does. Committing headers fixes the HTTP status, and extractErrorStatus (SEP-2575, protocol >= 2026-07-28) needs to set 404/400 for CodeMethodNotFound, CodeInvalidParams, CodeUnsupportedProtocolVersion and CodeMissingRequiredClientCapabilities after the stream exists. An unconditional early flush breaks TestStreamableStateless_NewProtocolSession_NoFakeInit with a superfluous response.WriteHeader from deliverLocked.

A small delay resolves it: those protocol-level errors are produced without any I/O, so a stream still silent after a short interval is a genuinely long-running call. I have a change that does this — a flushEarlyAfter on stream that waits ~1s, then commits headers and writes an SSE comment under stream.mu (serialising with deliverLocked, the only other writer to s.w), plus a headersFlushed flag so the override-status path is skipped once headers are out. The full suite passes, including under -race, and the reproduction above passes.

Happy to send it as a PR if you agree with the direction. I'm equally happy if you would rather have it opt-in via StreamableHTTPOptions, gated on protocol version, or shaped differently — filing this first per CONTRIBUTING.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues, valuable feature requests

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions