modelcontextprotocol / modelcontextprotocol/go-sdk
Streamable HTTP: POST response writes nothing until the request completes, so long tool calls look like a dead connection
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 5.1k
- Forks
- 543
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 37
Description
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at NewStreamableHTTPHandler and acquireStream, comparing the standalone GET stream with the POST tools/call path and its deliverLocked writes. Run TestStreamableStateless_NewProtocolSession_NoFakeInit and the minimal slow-tool reproduction; done means long POST calls establish response headers promptly without breaking protocol error statuses, the full suite, or -race.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100