Skip to content

Keep a request's messages on the stream that carried it - #2097

Draft
Karavil wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
Karavil:fix/route-responses-to-originating-stream
Draft

Karavil wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
Karavil:fix/route-responses-to-originating-stream

Conversation

@Karavil

@Karavil Karavil commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

When several callers share one MCP session and reuse JSON-RPC ids, a tools/call result can reach the wrong caller.

StreamableHTTPServerTransport.sendForRequest (agents 0.17.3, patched in patches/agents@0.17.3.patch) picks the target stream by JSON-RPC id. It prefers the stream that carried the request only while that stream's connection is live. The patch ends that connection early on purpose: the POST bridge closes it when a write to the client fails, and a Last-Event-ID resume supersedes it. After that, the message goes to:

  • the one other live stream that holds the same id, which gets a valid-looking answer to someone else's call, or
  • when two or more streams hold the id, each of them, as -32603 Internal error. A stream whose only open request had that id then closes, so its own result has nowhere to go later.

The MCP spec says a requestor must not reuse an id within a session, so this needs a client that pools callers on one session. We have seen callers of an executor.sh endpoint get another caller's result, and we suspect that kind of client. We could not see the server side, so the reproduction below is our evidence for the mechanism.

This change keeps a request's messages on the stream that carried it: the GET that resumed it, else its POST connection, else that stream's own event log. The async context is trusted only while that stream still owns the id. Otherwise routing falls back to the id lookup, as before. So a related message sent after the stream's final response still goes by id.

Reproduction: local workerd, agents 0.17.3 with this repo's patch, MCP SDK 1.29.0 clients. Several clients share one session, and each numbers its ids from 0. In the first two rows and the control, enable_request_signal lets the bridge see the hang-up locally. The third row uses this repo's own compatibility flags.

Scenario Before After
Caller 1 stops reading a 35 s call. Caller 2 (same session, same id) is waiting when it lands. 3/3 caller 2 gets caller 1's result 3/3 own result
6 callers for 120 s on one session; 30% of calls outlive their caller 455 calls: 12 foreign, 392 -32603, 40 own, 11 abandoned 182 calls: 0 foreign, 0 errors, 132 own, 50 abandoned
Caller X's POST stream drops and the SDK resumes it. Caller Y waits on the same id. 6/6 -32603 6/6 own result
One session per caller (control) 177 calls: 0 foreign 176 calls: 0 foreign

A caller starts its next call 1 s after its last one ends, so fast errors let the "before" run make more calls. We can share the harness if that helps.

packages/hosts/cloudflare/src/mcp/agents-request-routing.test.ts pins this against the real patched transport. 4 of its 6 cases fail on main. The package suite (132 tests), typecheck, oxlint, and oxfmt pass.

Separate issue, not changed here: responses replayed onto a fresh GET

A GET without Last-Event-ID gets the stored POST responses replayed onto it (replayUndeliveredResponsesOnFreshGet). The MCP spec (2025-11-25, Transports, Listening for Messages from the Server) says: "The server MUST NOT send a JSON-RPC response on the stream unless resuming a stream associated with a previous client request." On a shared session, a client that opens a fresh GET while its own call is pending can have that call resolved by another caller's stored result, or by the session's initialize result. The same harness shows this 3/3, before and after this change. The replay is a deliberate at-least-once fallback, so we left it alone. If you want it tightened, we can send a follow-up, for example one that skips a stored response while another in-flight stream still holds its id.

The same routing code is in cloudflare/agents packages/agents/src/mcp/server/transport.ts (0.24.0 and main).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant