Skip to content

Track MCP SDK fix for implicit relatedRequestId on server-to-client requests #1513

@threepointone

Description

@threepointone

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

  1. Inside an inbound tools/call handler, server.elicitInput() should call transport send() with options.relatedRequestId equal to the inbound request id.
  2. Inside an inbound handler, server.createMessage() should pass the same related request id.
  3. Inside an inbound handler, server.listRoots() should pass the same related request id.
  4. Inside an inbound handler, server.sendLoggingMessage() / notification() should pass the same related request id.
  5. Outside any inbound handler, those APIs should not synthesize relatedRequestId.
  6. Explicit options.relatedRequestId should override active context.
  7. Concurrent inbound requests that both call elicitInput() should send each outbound request with its own originating request id.
  8. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingmcpIssues related to MCP functionality

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions