cloudflare / cloudflare/agents
`StreamableHTTPServerTransport.send` silently drops server-initiated messages when no `_standaloneSse`-flagged connection exists; the caller hangs for the MCP SDK's `DEFAULT_REQUEST_TIMEOUT_MSEC` with no error
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
## Summary
In `agents@0.12.3`, `StreamableHTTPServerTransport.send()` silently no-ops when no DO `Connection` has `state._standaloneSse === true`. Server-initiated server-to-client requests (`elicitation/create`, presumably `sampling/createMessage` and `roots/list`) and notifications (including the SDK's own `notifications/cancelled` from its timeout handler) are dropped without writing to any stream and without throwing. The caller — typically `Server.request` from `@modelcontextprotocol/sdk` — then waits the timeout passed to its `options.timeout`, defaulting to `DEFAULT_REQUEST_TIMEOUT_MSEC` (60000ms; `@modelcontextprotocol/sdk/dist/esm/shared/protocol.js:8`), then throws `MCP error -32001: Request timed out` with `data:{timeout:60000}`. The user sees an opaque transport-error with no log to point at.
This reproduces deterministically with Claude Code's MCP client against a deployed Worker `McpAgent`, and does NOT reproduce with MCP Inspector configured for Streamable HTTP. The difference is the long-lived GET `/mcp` channel: Inspector keeps one open all session, which causes `handleGetRequest` to set `_standaloneSse=true` on the bound connection. Claude Code's GET appears in `wrangler tail` as `Canceled` at the same instant as the triggering POST, so by the time the elicit dispatches ~tens of milliseconds later, no flagged connection exists.
CC's behavior is fully spec-conformant under the [2025-11-25 Streamable HTTP spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports). It sets `accept: "application/json, text/event-stream"`, `content-type: "application/json"` — both of the Accept content types required by the spec. The spec's wording on the return GET is `MAY` ("The client MAY issue an HTTP GET to the MCP endpoint"), not `MUST`. Further, "These messages SHOULD be unrelated to any concurrently-running JSON-RPC request from the client" - in the case of an elicitation, it is related to an in-flight request so it should go back via that connection ("The server MAY send JSON-RPC requests and notifications before sending the JSON-RPC response. These messages SHOULD relate to the originating client request").
For context, these issues have been raised previously relating to the elicitation code path: #1490 (ALS lost across Worker-Loader-child→host RPC, fixed user-side via `agentContext.run` wrap) and #1491 (the `openApiMcpServer` validator-default bug, fixed user-side by swapping `Server._jsonSchemaValidator`). Both prior workarounds are engaged on the deployment that produced these traces..
## Affected versions
- `agents@^0.12.3` (verified failure on `0.12.3`).
- Workers runtime: any version, with `compatibility_date` settings used together with `@cloudflare/codemode@^0.3.4` for the per-tool-call POST shape.
- Independent of MCP SDK version; the bug is in `agents`' `send()` body, not in the SDK.
## Prior relevant issues / PRs
- `cloudflare/agents` **#714** ("Elicitation not working with Streamable HTTP transport") — closed in `agents@0.2.30` via PR #715, which added `_handleElicitationResponse` interception so accept responses on the POST stream get matched. That fix is present in `agents@0.12.3` (4 callsites of `_handleElicitationResponse` in dist) and Inspector confirms the elicit path is healthy on a session that DOES have a standalone-SSE connection. This is a different issue — not a missing interception on the way back, but a missing channel on the way out.
- `cloudflare/agents` **#1371** (`responseStreamDisconnected` on GET `/mcp` per-file) — adjacent stream-lifecycle quirks. May overlap with whatever is causing CC's GET to read as `Canceled` so quickly, but the lifecycle question isn't the bug I'm filing here.
## Reproduction
An `McpAgent` whose tools dispatch through `@cloudflare/codemode`'s `openApiMcpServer`, with at least one operation that calls `elicitInput` to confirm before proceeding. Call the elicit-gated tool from Inspector: works end-to-end. Call the same tool from Claude Code: hangs `DEFAULT_REQUEST_TIMEOUT_MSEC` (60s), returns `MCP error -32001: Request timed out`, no confirmation prompt ever surfaces in the client UI.
I patched the installed `agents@0.12.3` dist with seven `console.log` probes (full patch and capture pack: shareable on request) at:
| # | Site | Stage label |
|---|---|---|
| 1 | `StreamableHTTPServerTransport.send` entry | `send-entry` |
| 2 | The `if (standaloneConnection === void 0) return;` early-return path | `send-no-standalone` (logs `connectionCount`, `connectionsFlagged`) |
| 3 | Just before `writeSSEEvent(standaloneConnection, …)` | `send-pre-write channelKind:"standalone-sse"` |
| 4 | Just before `writeSSEEvent(connection, …, shouldClose)` | `send-pre-write channelKind:"per-request-sse"` |
| 5 | `createStreamingHttpHandler` after `new TransformStream()` | `get-stream-open` |
| 6 | `ws.addEventListener("close", …)` body | `get-stream-close` |
| 7 | `_handleElicitationResponse(message)` body | `elicit-response-handled` |
Wrangler-tail probe trail from a Claude Code session (truncated to the relevant window):
```
13:19:28.305 send-entry jsonrpc:{method:"elicitation/create", id:0} relatedRequestId:null
13:19:28.305 send-no-standalone connectionCount:1 connectionsFlagged:0 ← early return; no write
[no probe #3 or #4 follows; SDK Server.request waits]
13:20:28.305 send-entry jsonrpc:{method:"notifications/cancelled"} relatedRequestId:null
13:20:28.305 send-no-standalone connectionCount:1 connectionsFlagged:0 ← cancellation notif also dropped
13:20:28.305 [user-side caller catches] McpError -32001 Request timed out
13:20:28.305 send-entry jsonrpc:{id:4} relatedRequestId:null
13:20:28.305 send-pre-write channelKind:"per-request-sse" connectionId:mm3lnV…
requestId:4 shouldClose:true relatedIdsCount:1
← final tool-call response shipped via per-request-SSE; that channel IS alive
```
`connectionCount:1` confirms a DO connection exists for the session. `connectionsFlagged:0` confirms NONE of those connections has `state._standaloneSse=true`. The `wrangler tail` request line for the GET reads `GET /mcp - Canceled` at the same instant as the triggering POST.
For comparison, the same trace on Inspector ends at `elicit-response-handled requestId:0 isResult:true` followed by an `accepted` audit and the upstream call. Inspector's standalone connection was alive throughout.
## Root cause
`agents@0.12.3` `dist/mcp/index.js` lines 666–672 (`StreamableHTTPServerTransport.send` body, after the standalone-message branch is taken):
```js
let standaloneConnection;
for (const conn of agent.getConnections()) if (conn.state?._standaloneSse) standaloneConnection = conn;
if (standaloneConnection === void 0) return;
let eventId;
if (this._eventStore) eventId = await this._eventStore.storeEvent(standaloneConnection.id, message);
this.writeSSEEvent(standaloneConnection, message, eventId);
return;
```
The unconditional silent `return` is set up differently to the per-request branch a few lines below — `if (!connection) throw new Error('No connection established for request ID: ${String(requestId)}')`. But on the standalone path, it doesn't throw any error.
The deeper question of why `_standaloneSse=true` is never set on a CC session is interesting (CC's GET lifecycle differs from MCP Inspector's), but both lifecycles are spec-permitted.
## Workaround
I'm running with both prior elicit workarounds engaged (#1490 ALS + #1491 validator).
To that I added a user-side monkey-patch on `transport.send` at `McpAgent.init()` time, which populates `options.relatedRequestId` (as per the suggestion below). This appears to work. I don't like it, because it binds to `_`-prefixed internals, but happy to share what I did.
## Suggested fix
From the [2025-11-25 Streamable HTTP spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), § "Sending Messages to the Server":
> "The server MAY send JSON-RPC requests and notifications before sending the JSON-RPC response. These messages SHOULD relate to the originating client request."
An elicit fired inside an in-flight `tools/call` is related to the originating client request, so I think the per-request POST response stream is right delivery channel. This channel is available according to the trace above (the final `send-pre-write channelKind:"per-request-sse"` for response id:4). `send()` just doesn't route there because `options.relatedRequestId` is undefined: nothing in the MCP SDK threads the originating request id through `Server.elicitInput → Server.request → transport.send`.
I'd suggest: when `send()` is called server-initiated, read the active request id from an `AsyncLocalStorage` populated by the handler-dispatch path and route via the existing per-request branch. The library already maintains ALS for `agentContext`; adding an active-request-id slot would be a small change. Write with `shouldClose:false` so the per-request stream stays open for the eventual tool-call response.
A naive "pick any in-flight connection" heuristic would also fix the symptom, especially if clients keep the GET connection open for SSE events, but I think it would violate the same SHOULD under concurrent tool calls even though this is how MCP Inspector works with the library currently, without this workaround.
There's also a cleaner fix possible the MCP SDK itself: have `Server.elicitInput` (and other server→client request methods) automatically pass `options.relatedRequestId` derived from the currently-handling request. That would fix every transport, not just `agents`, and would mean `agents` doesn't need to ALS-introspect at all. That's alternative issue that could be filed against `@modelcontextprotocol/sdk` — happy to file it as a companion if maintainers here agree it's the right direction.
I'm new to this codebase and Claude did most of the investigation, so this is offered as a starting point.
Contributor guide
Assessment
This issue has not been assessed yet.