A tool call freezes for exactly 45 seconds, then fails. The user gets a permission popup 45 seconds too late, answers it, and the answer goes nowhere. Nothing crashed and no error was logged on the way in. That is the bug report that landed in mcp-go#932 a few days ago, observed with one Codex Desktop setup against a server built on mcp-go, a widely used Go implementation of the Model Context Protocol.
I maintain a couple of MCP-adjacent packages and had two transport PRs open on mcp-go already, so I went to reproduce it. The fix ended up being one PR, currently in review. The bug class is bigger than the library, and with the next MCP spec release landing on July 28 it is about to get more common, not less. Here is the whole trail.
Elicitation in sixty seconds
MCP servers do not just answer requests. Mid-request, a server can ask the client a question and wait for the answer. The most visible form is elicitation: a tool handler decides it needs user confirmation, sends an elicitation/create request to the client, the client shows a popup, and the tool call continues with the user's answer.
So inside one tools/call there is a nested round trip in the opposite direction. Server-to-client requests are ordinary JSON-RPC, and the interesting question is purely a transport one: on which HTTP connection does that nested request travel?
Two streams, two rules
MCP's Streamable HTTP transport gives a client two ways to receive server messages.
The first is the POST itself. When the client POSTs tools/call, the server may answer with Content-Type: text/event-stream and hold the connection open. Before the final response event, the server is allowed to push other messages onto this stream. The spec has one sentence about which ones belong there:
The server MAY send JSON-RPC requests and notifications before sending the JSON-RPC response. These messages SHOULD relate to the originating client request.
The second is a standalone GET. The client may open a long-lived SSE stream with no request in flight, so the server can reach it at any time. The spec mirrors the rule:
These messages SHOULD be unrelated to any concurrently-running JSON-RPC request from the client.
An elicitation triggered by an in-flight tools/call is about as "related to the originating request" as a message can be. Both sentences point the same way: it belongs on the POST stream.
mcp-go sent it to the GET stream.
The circular wait
With a client that dispatches server requests from whichever stream it is currently reading, that routing choice turns into a deadlock with a timer on it:
- Client POSTs
tools/calland starts reading the POST's SSE stream for the response. - The tool handler calls
RequestElicitationand blocks waiting for the answer. - The server queues
elicitation/createon the standalone GET stream. - The client is not dispatching from the GET stream right now. It is waiting on the POST.
- Server waits for the client. Client waits for the server. Nobody moves.
- After the server-side timeout, 45 seconds in the reported setup, the tool call fails.
- The client finally processes the queued elicitation and shows the popup.
- The answer arrives for a request the server has already forgotten.
Step 8 is my favorite detail. The pending-request entry was deleted when the timeout fired, so the late answer does not even produce an error the user can see. It returns 400 into the void.
Whether the client should also have been reading the GET stream the whole time is a fair question, and the issue is careful to say the client-side cause was not confirmed. But the spec's SHOULDs exist exactly so that a server does not bet a user-visible request on the client's stream-juggling. The server was in a position to remove the ambiguity and did not.
Why the server got it wrong
Inside mcp-go's server, the POST handler runs a small forwarder goroutine that pumps queued notifications onto the POST's SSE stream while the handler executes. Requests to the client, though, all went through channels that only the GET handler drains:
// POST handler: pumps notifications onto this POST's stream
case nt := <-session.notificationChannel:
writeSSEEvent(w, nt)
// GET handler: the only reader of server-to-client requests
case elicitationReq := <-session.elicitationRequestChan:
writeSSEEvent(w, jsonrpcRequestFor(elicitationReq))
Nothing here is careless. Notifications already had a request-scoped path, and requests had a working delivery path too, as long as a GET stream existed and the client read it promptly. The gap only shows when those assumptions meet a client that serializes its attention.
The fix: route by request, not by connection
The change gives the POST handler a request-scoped sender and puts it into the handler's context. When a tool handler triggers an elicitation, delivery prefers the stream of the request that caused it:
type requestScopedSSE struct {
requests chan<- mcp.JSONRPCRequest
done <-chan struct{}
register func()
}
func (r *requestScopedSSE) trySend(req mcp.JSONRPCRequest) bool {
select {
case <-r.done: // POST already finished
return false
default:
}
r.register() // make responses routable for this POST
select {
case r.requests <- req:
return true
case <-r.done:
return false
default:
return false
}
}
RequestElicitation builds the JSON-RPC request, tries the scoped sender first, and falls back to the old GET-stream channel when there is no POST to ride on or it has already finished. The forwarder goroutine gained one more case, writing scoped requests to the POST stream exactly like notifications.
The regression test runs the whole loop over real HTTP with no GET stream open at all: POST a tools/call, read elicitation/create off the POST's own SSE stream, answer it with a separate POST, then read the final tool result off the original stream. On master the test times out. With the fix it passes in half a second.
What review caught that I missed
Two edge cases came out of review on the PR, and both were real.
First, responses have to find their way back. Pending elicitations were tracked per session object, and without a GET stream two concurrent POSTs for the same session ID got two separate ephemeral session objects. Each kept its own request-ID counter starting at 1. Two counters at 1 means an answer for one POST's elicitation could resolve against the other POST's pending entry. Not a timeout, a wrong delivery. The counter moved to the server:
// before: per session object
requestID := s.requestIDCounter.Add(1) // two objects, both start at 1
// after: one counter per server, shared by every session object
requestIDCounter *atomic.Int64 // IDs stay unique within a session ID
Second, the server's heartbeat pings kept their own per-session counters in a separate map. A ping and an elicitation on the same session could both go out as id: 1, which JSON-RPC flatly forbids. Same treatment, and the map plus its cleanup code disappeared entirely. A new test reads a ping and an elicitation off one stream and asserts the IDs never collide.
I would have shipped both bugs. The lesson is old but it aged well: ID allocation and routing are one design decision, not two. If delivery is request-scoped, identity has to be scoped to match, or the responses cross wires.
July 28 raises the stakes
The next MCP spec release is a week away, and its release candidate has been public since May: a stateless core that runs on ordinary HTTP infrastructure, with the initialize handshake and protocol-level sessions removed.
Stateless deployments are exactly the ones where a standalone GET stream is least likely to exist. No session, no long-lived listener, just POSTs carrying requests and answers. In that world, the POST stream is not the preferred place for a request-scoped server question. It is the only place. Transport implementations that treat the GET stream as the default path for server-to-client requests will find that default quietly vanishing under them.
If you are implementing or wrapping this transport in any language, the checklist that falls out of this bug:
- A server-to-client request caused by an in-flight client request rides that request's stream. The GET stream is for the rest.
- Fall back, do not fail: no stream to ride means the standalone stream is still better than dropping the message.
- Make response routing work for every path a request can take. Delivery without routability is a slower timeout.
- One ID space per session, one allocator. Pings count too.
- Test the no-GET-stream case end to end. After July 28 that is not an edge case, it is the deployment model.
The fix is in review. The 45 seconds were never the bug. They were the time it took the real bug to become visible.
Top comments (2)
Excellent transport-level post. One detail in
trySendlooks worth stress-testing: thedefault: return falsetreats temporary backpressure on an active POST stream the same as “this stream is unavailable.” If the forwarder is briefly busy, falling back to GET can reintroduce cross-stream routing and ordering surprises even though the request-scoped path is still valid.I would fall back only when the scoped stream is closed or definitively absent; while it is active, send with a bounded context/deadline and surface backpressure explicitly. Add tests with a deliberately slow POST reader, several concurrent elicitations, notifications interleaved with requests, client disconnect during elicitation, and a late response after timeout. Keeping a short tombstone for timed-out/cancelled request IDs would also let the server return a meaningful “request expired” result instead of an unexplained 400. The deeper invariant is useful: parent request, outbound request ID, response, cancellation, and stream route should share one observable lifecycle.
You're right, and it was a real gap, not a theoretical one: with a slow POST reader and enough concurrent server requests the overflow would spill to the GET stream, and those requests would degrade to exactly the pre-fix behavior. I've changed the PR: trySend now takes the caller's context and waits for buffer space while the POST stream is active, falling back only when the stream is finished or the context ends. There's a test pinning the waiting behavior (full buffer, drain, cancelled context): github.com/mark3labs/mcp-go/pull/9...
The tombstone idea is good too. Today a late response to a timed-out request gets a 400 "no pending request" that doesn't distinguish an expired request from one that never existed; a short-TTL record of expired IDs would make that a meaningful "request expired". I left it out to keep this PR reviewable, but it's on my list.
Thanks for reading it this closely.