modelcontextprotocol / modelcontextprotocol/go-sdk
jsonrpc2.Connection.processResult silently discards write errors for late responses, giving no signal when a response can't be delivered
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?
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.LoggerthroughServer/Connectionin 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 existingonInternalErrorpattern) 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:
- #1061 — stdio EOF causes responses to already-received requests to be dropped (a different mechanism: a
shuttingDown()guard rejects the write outright, rather than a write that fails after the fact). - #1098 / PR #1105 — the
writeErra few lines above our TODO is formatted with%vinstead of%w, breakingerrors.Is. - #1100 / PR #1103 — resolves a different TODO in
handleAsync(attributing cancellation cause), not the response-write TODO this issue is about.
Versions
github.com/modelcontextprotocol/go-sdkv1.7.0- Go 1.26.5
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 in internal/jsonrpc2/conn.go at Connection.processResult, then trace c.write and the streamable HTTP Write path in mcp/streamable.go. Review the existing *slog.Logger plumbing and onInternalError pattern mentioned in the issue. Done means a failed late response is observable through the selected logging, metric, or callback mechanism instead of being silently discarded.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, backend, observability
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100