What did you do?
Ran a long-running MCP tool handler (a multi-turn, LLM-backed tool) behind a streamable-HTTP client that enforces its own context.WithTimeout on each tool call. Under load, the handler occasionally took longer to finish than the client's timeout budget allowed.
What did you see?
The client behaved exactly as expected: its context deadline fired, the call returned a timeout error, and it tore down the underlying stream for that request. That part is fine.
The problem is server-side: when the handler goroutine (unaware the client already gave up — Go doesn't preempt goroutines on context cancellation, and this handler doesn't check ctx.Err() itself yet) eventually finished and processResult tried to write the response, the write failed (streamableServerConn.Write returns %w: write to closed stream once the client-side stream is gone — mcp/streamable.go). That failure reaches processResult's final error-handling block and is dropped completely: no log line, no metric, no callback, nothing. From the server's own structured logs, this is indistinguishable from "nothing happened at all."
We only found the mechanism by reading processResult's source directly, after spending significant time ruling out every other hypothesis (goroutine leak, panic, deadlock) via pprof goroutine dumps that showed nothing stuck or blocked anywhere.
What did you expect to see?
Since a client giving up after its own timeout is normal, expected behavior, we'd expect the server side to at least be able to tell — via its own logs/metrics — that a computed response failed to be delivered, rather than that failure being invisible by construction. Today there is no way to diagnose this scenario from server-side telemetry alone.
Where (mechanism)
internal/jsonrpc2/conn.go, processResult (current main, same in v1.7.0):
if req.IsCall() {
...
if respErr == nil {
writeErr := c.write(notDone{req.ctx}, response)
if err == nil {
err = writeErr
}
}
...
}
...
if err != nil {
// TODO: can/should we do anything with this error beyond writing it to the event log?
// (Is this the right label to attach to the log?)
}
The err captured from c.write(...) reaches this final if err != nil block and is dropped there. There is no log call, no metric, no callback — the TODO itself acknowledges the gap.
Reproduction status
We have not yet built an isolated minimal repro (this was found via production incident investigation — log correlation plus pprof goroutine dumps — not a from-scratch reproduction). The mechanism is straightforward to construct against the streamable HTTP transport (slow handler + short client-side timeout), and we're happy to put one together if that would help triage.
Ask
Would the maintainers be open to one of:
- Logging this case (the SDK already threads a
*slog.Logger through Server/Connection in some paths — could this hook into that?), even at a low level, so it's at least discoverable from server-side logs, or
- Exposing an optional callback/hook (e.g. an
OnLateResponse(req, err) or reusing/extending the existing onInternalError pattern) so integrators can at least observe/metric this, or
- Some other mechanism the maintainers consider more idiomatic for this SDK.
Happy to attempt a PR for whichever approach is preferred. This isn't urgent/blocking on our side — we're adding our own bounded timeout on the handler side as the immediate mitigation — but it seems worth tracking since it makes a real failure mode invisible by design.
Related issues
Related but distinct reports in the same area of internal/jsonrpc2/conn.go, surfaced while investigating this:
Versions
github.com/modelcontextprotocol/go-sdk v1.7.0
- Go 1.26.5
What did you do?
Ran a long-running MCP tool handler (a multi-turn, LLM-backed tool) behind a streamable-HTTP client that enforces its own
context.WithTimeouton each tool call. Under load, the handler occasionally took longer to finish than the client's timeout budget allowed.What did you see?
The client behaved exactly as expected: its context deadline fired, the call returned a timeout error, and it tore down the underlying stream for that request. That part is fine.
The problem is server-side: when the handler goroutine (unaware the client already gave up — Go doesn't preempt goroutines on context cancellation, and this handler doesn't check
ctx.Err()itself yet) eventually finished andprocessResulttried to write the response, the write failed (streamableServerConn.Writereturns%w: write to closed streamonce the client-side stream is gone —mcp/streamable.go). That failure reachesprocessResult's final error-handling block and is dropped completely: no log line, no metric, no callback, nothing. From the server's own structured logs, this is indistinguishable from "nothing happened at all."We only found the mechanism by reading
processResult's source directly, after spending significant time ruling out every other hypothesis (goroutine leak, panic, deadlock) viapprofgoroutine dumps that showed nothing stuck or blocked anywhere.What did you expect to see?
Since a client giving up after its own timeout is normal, expected behavior, we'd expect the server side to at least be able to tell — via its own logs/metrics — that a computed response failed to be delivered, rather than that failure being invisible by construction. Today there is no way to diagnose this scenario from server-side telemetry alone.
Where (mechanism)
internal/jsonrpc2/conn.go,processResult(currentmain, same in v1.7.0):The
errcaptured fromc.write(...)reaches this finalif err != nilblock and is dropped there. There is no log call, no metric, no callback — theTODOitself acknowledges the gap.Reproduction status
We have not yet built an isolated minimal repro (this was found via production incident investigation — log correlation plus
pprofgoroutine dumps — not a from-scratch reproduction). The mechanism is straightforward to construct against the streamable HTTP transport (slow handler + short client-side timeout), and we're happy to put one together if that would help triage.Ask
Would the maintainers be open to one of:
*slog.LoggerthroughServer/Connectionin some paths — could this hook into that?), even at a low level, so it's at least discoverable from server-side logs, orOnLateResponse(req, err)or reusing/extending the existingonInternalErrorpattern) so integrators can at least observe/metric this, orHappy to attempt a PR for whichever approach is preferred. This isn't urgent/blocking on our side — we're adding our own bounded timeout on the handler side as the immediate mitigation — but it seems worth tracking since it makes a real failure mode invisible by design.
Related issues
Related but distinct reports in the same area of
internal/jsonrpc2/conn.go, surfaced while investigating this:shuttingDown()guard rejects the write outright, rather than a write that fails after the fact).errors.Is(err, io.EOF)is false for consumers #1098 / PR internal/jsonrpc2: wrap writeErr with %w so errors.Is works for consumers #1105 — thewriteErra few lines above our TODO is formatted with%vinstead of%w, breakingerrors.Is.handleAsync(attributing cancellation cause), not the response-write TODO this issue is about.Versions
github.com/modelcontextprotocol/go-sdkv1.7.0