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.
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):Then initialize, and POST a
tools/callforslowwith a 5s context while the tool is still blocked.What did you see?
The POST does not return response headers at all.
http.Client.Donever returns while the tool is running: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.
acquireStreamalready does this fors.id == "", added for #410, with a comment explaining that aFlushalone 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-runningtools/callis 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-streamin response to this HTTP GET, or else return HTTP 405". For POST carrying a request: the server "MUST either returnContent-Type: text/event-stream, to initiate an SSE stream, orContent-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/arm64Complication 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 forCodeMethodNotFound,CodeInvalidParams,CodeUnsupportedProtocolVersionandCodeMissingRequiredClientCapabilitiesafter the stream exists. An unconditional early flush breaksTestStreamableStateless_NewProtocolSession_NoFakeInitwith asuperfluous response.WriteHeaderfromdeliverLocked.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
flushEarlyAfteronstreamthat waits ~1s, then commits headers and writes an SSE comment understream.mu(serialising withdeliverLocked, the only other writer tos.w), plus aheadersFlushedflag 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.