Summary
This tracks the follow-up from #1510: we should file an upstream issue, and likely a PR, against @modelcontextprotocol/sdk so server-side convenience APIs automatically route server-to-client messages through the originating client request when they are called from inside a request handler.
We have a transport-side mitigation in agents for Streamable HTTP, but it is necessarily heuristic. The durable/correct fix belongs in the MCP SDK because the SDK is the layer that knows the request handler currently executing and the exact originating JSON-RPC request id.
Context
In Streamable HTTP, clients are not required to keep a standalone GET /mcp SSE stream open. The spec permits the server to send JSON-RPC requests and notifications before the response on the originating POST stream when those messages relate to the client request being handled.
This matters for server-to-client flows such as:
elicitation/create
sampling/createMessage
roots/list
- related notifications such as
notifications/cancelled, progress, or logging messages produced while handling a request
Today, SDK convenience APIs like Server.elicitInput() call Protocol.request() without automatically passing options.relatedRequestId, even when they are invoked from inside a handler for a client request.
In the SDK code path, Protocol._onrequest() already knows the right id:
- It receives the inbound request.
- It constructs
fullExtra.requestId = request.id.
- It provides
extra.sendRequest() and extra.sendNotification() helpers that do pass relatedRequestId: request.id.
But the high-level server APIs do not inherit that relationship automatically:
Server.elicitInput(params, options) -> this.request({ method: \"elicitation/create\", ... }, ..., options)
Server.createMessage(params, options) -> this.request({ method: \"sampling/createMessage\", ... }, ..., options)
Server.listRoots(params, options) -> this.request({ method: \"roots/list\", ... }, ..., options)
- notification convenience APIs such as logging/resource/tool/prompt notifications also do not automatically inherit request context
So application code that does the natural thing inside a tool handler:
server.tool("dangerousAction", {}, async () => {
const approval = await server.server.elicitInput({
message: "Proceed?",
requestedSchema: { type: "object", properties: {} }
});
return { content: [{ type: "text", text: approval.action }] };
});
ends up sending elicitation/create with no relatedRequestId. Transports then treat the message as standalone. In agents Streamable HTTP this meant the message was written to the standalone GET stream if present, or silently dropped if no standalone GET stream existed, causing the SDK request to hang until DEFAULT_REQUEST_TIMEOUT_MSEC.
Why the agents transport patch is not enough
The agents Streamable HTTP transport can mitigate the common case by tracking an active request id via transport-local async context and falling back to the only active POST stream when unambiguous. That helps with clients that do not maintain GET /mcp.
But the transport cannot be perfectly correct because it does not own the semantic parent-child relationship between inbound and outbound JSON-RPC messages.
Remaining limitations that should be fixed at SDK level:
-
Cross-isolate / RPC async context loss
- Transport-local
AsyncLocalStorage can be lost across Worker Loader child -> host RPC, service binding calls, DO RPC callbacks, or other runtime boundaries.
- The transport can only guess after that.
- The SDK has the true originating request id at handler dispatch time.
-
Concurrent POST ambiguity
- If multiple client requests are in flight and SDK context is lost, the transport cannot know which POST response stream should receive
elicitation/create or sampling/createMessage.
- A heuristic like “pick any active POST” can cross-wire concurrent tool calls.
- The SDK can preserve the exact request id when the convenience API is called inside a handler.
-
Convenience API ambiguity
- A
server.elicitInput() outside a request handler may be genuinely standalone.
- A
server.elicitInput() inside a request handler should be related to that handler's request.
- The transport sees only a JSON-RPC message and cannot reliably distinguish intent.
-
Task-aware routing
- The SDK already has task-related routing and queues (
relatedTask, task message queue, tasks/result).
- Transports should not replicate task semantics heuristically.
- SDK-side request context can decide whether a server-to-client request should be side-channeled, queued for task delivery, or sent on the current POST stream.
-
Durability / hibernation
- Agents can keep the DO alive around its custom
McpAgent.elicitInput() path, but SDK Server.elicitInput() pending response handlers are in memory.
- If a serverless/DO runtime hibernates while waiting for an elicitation/sampling response, pending response correlation may be lost.
- This may need a separate SDK design, but the same area should be considered when improving request correlation.
Proposed SDK change
Add SDK-owned active request context around request handler execution in Protocol._onrequest().
When Protocol._onrequest() invokes the registered handler for inbound request id request.id, store that id in SDK-owned async context for the duration of async work created by the handler.
Then, in SDK APIs that emit server-to-client messages:
Protocol.request() should default relatedRequestId from active request context when options.relatedRequestId is not explicitly provided.
Protocol.notification() should do the same for notifications emitted during a handler, unless options explicitly opt out.
- Alternatively, apply this default in higher-level
Server APIs (elicitInput, createMessage, listRoots, logging notifications), but centralizing in Protocol.request() / Protocol.notification() seems less error-prone.
Pseudo-shape:
const activeRequestContext = new AsyncLocalStorage<RequestId>();
_onrequest(request, extra) {
// ...existing setup...
Promise.resolve()
.then(() => activeRequestContext.run(request.id, () => handler(request, fullExtra)))
// ...existing response/error handling...
}
request(request, resultSchema, options) {
const relatedRequestId =
options?.relatedRequestId ?? activeRequestContext.getStore();
// pass relatedRequestId into transport.send(...)
}
notification(notification, options) {
const relatedRequestId =
options?.relatedRequestId ?? activeRequestContext.getStore();
// pass relatedRequestId into transport.send(...)
}
Need careful handling for:
- Explicit
options.relatedRequestId should always win.
- Calls outside a handler should remain standalone (
undefined).
- Task-related options should preserve current task queue semantics.
- Request/error responses should continue routing by response id, not active context.
- Abort/cancel notifications should keep their intended relation.
Suggested SDK tests
Add transport-level tests with a fake transport that records send(message, options) calls:
- Inside an inbound
tools/call handler, server.elicitInput() should call transport send() with options.relatedRequestId equal to the inbound request id.
- Inside an inbound handler,
server.createMessage() should pass the same related request id.
- Inside an inbound handler,
server.listRoots() should pass the same related request id.
- Inside an inbound handler,
server.sendLoggingMessage() / notification() should pass the same related request id.
- Outside any inbound handler, those APIs should not synthesize
relatedRequestId.
- Explicit
options.relatedRequestId should override active context.
- Concurrent inbound requests that both call
elicitInput() should send each outbound request with its own originating request id.
- Task-related requests should preserve current queue / related-task behavior and not duplicate delivery.
Why this matters for agents
agents supports Streamable HTTP MCP servers on Workers. Some clients, including Claude Code in the reported reproduction, do not keep a standalone GET stream open. That client behavior is spec-permitted. Without SDK-level related request propagation, server-to-client requests emitted by convenience APIs are delivered only by transport-specific heuristics.
The upstream SDK fix would make every transport behave correctly and would let the agents Streamable HTTP transport remove or reduce fallback heuristics over time.
Summary
This tracks the follow-up from #1510: we should file an upstream issue, and likely a PR, against
@modelcontextprotocol/sdkso server-side convenience APIs automatically route server-to-client messages through the originating client request when they are called from inside a request handler.We have a transport-side mitigation in
agentsfor Streamable HTTP, but it is necessarily heuristic. The durable/correct fix belongs in the MCP SDK because the SDK is the layer that knows the request handler currently executing and the exact originating JSON-RPC request id.Context
In Streamable HTTP, clients are not required to keep a standalone GET
/mcpSSE stream open. The spec permits the server to send JSON-RPC requests and notifications before the response on the originating POST stream when those messages relate to the client request being handled.This matters for server-to-client flows such as:
elicitation/createsampling/createMessageroots/listnotifications/cancelled, progress, or logging messages produced while handling a requestToday, SDK convenience APIs like
Server.elicitInput()callProtocol.request()without automatically passingoptions.relatedRequestId, even when they are invoked from inside a handler for a client request.In the SDK code path,
Protocol._onrequest()already knows the right id:fullExtra.requestId = request.id.extra.sendRequest()andextra.sendNotification()helpers that do passrelatedRequestId: request.id.But the high-level server APIs do not inherit that relationship automatically:
Server.elicitInput(params, options)->this.request({ method: \"elicitation/create\", ... }, ..., options)Server.createMessage(params, options)->this.request({ method: \"sampling/createMessage\", ... }, ..., options)Server.listRoots(params, options)->this.request({ method: \"roots/list\", ... }, ..., options)So application code that does the natural thing inside a tool handler:
ends up sending
elicitation/createwith norelatedRequestId. Transports then treat the message as standalone. InagentsStreamable HTTP this meant the message was written to the standalone GET stream if present, or silently dropped if no standalone GET stream existed, causing the SDK request to hang untilDEFAULT_REQUEST_TIMEOUT_MSEC.Why the agents transport patch is not enough
The
agentsStreamable HTTP transport can mitigate the common case by tracking an active request id via transport-local async context and falling back to the only active POST stream when unambiguous. That helps with clients that do not maintain GET/mcp.But the transport cannot be perfectly correct because it does not own the semantic parent-child relationship between inbound and outbound JSON-RPC messages.
Remaining limitations that should be fixed at SDK level:
Cross-isolate / RPC async context loss
AsyncLocalStoragecan be lost across Worker Loader child -> host RPC, service binding calls, DO RPC callbacks, or other runtime boundaries.Concurrent POST ambiguity
elicitation/createorsampling/createMessage.Convenience API ambiguity
server.elicitInput()outside a request handler may be genuinely standalone.server.elicitInput()inside a request handler should be related to that handler's request.Task-aware routing
relatedTask, task message queue,tasks/result).Durability / hibernation
McpAgent.elicitInput()path, but SDKServer.elicitInput()pending response handlers are in memory.Proposed SDK change
Add SDK-owned active request context around request handler execution in
Protocol._onrequest().When
Protocol._onrequest()invokes the registered handler for inbound request idrequest.id, store that id in SDK-owned async context for the duration of async work created by the handler.Then, in SDK APIs that emit server-to-client messages:
Protocol.request()should defaultrelatedRequestIdfrom active request context whenoptions.relatedRequestIdis not explicitly provided.Protocol.notification()should do the same for notifications emitted during a handler, unless options explicitly opt out.ServerAPIs (elicitInput,createMessage,listRoots, logging notifications), but centralizing inProtocol.request()/Protocol.notification()seems less error-prone.Pseudo-shape:
Need careful handling for:
options.relatedRequestIdshould always win.undefined).Suggested SDK tests
Add transport-level tests with a fake transport that records
send(message, options)calls:tools/callhandler,server.elicitInput()should call transportsend()withoptions.relatedRequestIdequal to the inbound request id.server.createMessage()should pass the same related request id.server.listRoots()should pass the same related request id.server.sendLoggingMessage()/notification()should pass the same related request id.relatedRequestId.options.relatedRequestIdshould override active context.elicitInput()should send each outbound request with its own originating request id.Why this matters for agents
agentssupports Streamable HTTP MCP servers on Workers. Some clients, including Claude Code in the reported reproduction, do not keep a standalone GET stream open. That client behavior is spec-permitted. Without SDK-level related request propagation, server-to-client requests emitted by convenience APIs are delivered only by transport-specific heuristics.The upstream SDK fix would make every transport behave correctly and would let the
agentsStreamable HTTP transport remove or reduce fallback heuristics over time.