Skip to content

everything-server sampling/elicitation use server.request() without relatedRequestId, causing intermittent 60s timeouts #407

Description

@jhrozek

Summary

The reference server examples/servers/typescript/everything-server.ts issues its
server→client requests (sampling/createMessage, elicitation/create) via
mcpServer.server.request(...) directly, instead of via the tool handler's
extra.sendRequest(...). As a result these requests carry no relatedRequestId.

In the MCP TypeScript SDK's Streamable HTTP server transport, a server→client
message with no relatedRequestId is routed to the standalone GET /mcp SSE
stream
rather than the SSE stream of the originating tools/call POST response.
If the client has not established that standalone GET stream yet, the SDK stores the
message to its event store and returns silently — it does not throw. The server's
request() promise then never resolves and eventually hits the 60s request timeout.

This makes tools-call-sampling (and, by the same mechanism, elicitation scenarios)
intermittently fail: the client opens the standalone GET stream shortly after
sending tools/call, so any added latency (e.g. a proxy in front of the server, or
normal scheduling jitter) lets the tool handler run and emit the sampling request
before the GET stream exists. The request lands on a not-yet-connected standalone
stream and is silently dropped.

Defective code

At the pinned release used by downstream conformance CI (v0.1.16)

examples/servers/typescript/everything-server.ts

  • test_sampling — handler registered at line 394; server→client request at line 404:
    // line 404
    const result = await mcpServer.server.request(
      {
        method: 'sampling/createMessage',        // line 406
        params: { messages: [...], maxTokens: 100 }
      },
      z.object({ method: z.literal('sampling/createMessage') }).passthrough() as any
    );
  • test_elicitation — handler registered at line 454; server→client request at line 464:
    // line 464
    const result = await mcpServer.server.request(
      {
        method: 'elicitation/create',            // line 466
        params: { message: args.message, requestedSchema: {...} }
      },
      ElicitResultSchema
    );

Neither handler receives or uses the extra argument, so relatedRequestId is never set.

At current main (HEAD)

Still present, unchanged. Same pattern at test_sampling (server.request at line 545)
and test_elicitation (server.request at line 605). The handler callbacks are
async (args) => { ... } and do not accept extra. (The same direct-server.request
pattern is also used by test_elicitation_sep1034_defaults and
test_elicitation_sep1330_enums.)

SDK routing mechanism

Confirmed against @modelcontextprotocol/sdk 1.27.1 (resolved by v0.1.16) and
1.29.0 (resolved by HEAD) — identical behavior; both route through
WebStandardStreamableHTTPServerTransport.

src/server/webStandardStreamableHttp.ts, inside send():

private _standaloneSseStreamId: string = '_GET_stream';
...
// Check if this message should be sent on the standalone SSE stream (no request ID)
if (requestId === undefined) {
  ...
  // Store even if stream is disconnected so events can be replayed on reconnect
  eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message);
  ...
  if (standaloneSse === undefined) {
    // Stream is disconnected - event is stored for replay, nothing more to do
    return;                       // <-- silent: no throw, promise never resolves
  }
}

src/shared/protocol.ts — what extra.sendRequest does differently:

const requestOptions = { ...options, relatedRequestId: request.id };
...
return await this.request(r, resultSchema, requestOptions);

relatedRequestId: request.id binds the server→client request to the in-flight
tools/call id, so the SDK delivers it on that call's POST response SSE stream, which
is open for the whole duration of the handler.

Why it manifests as an intermittent 60s timeout

The everything-server configures an event store
(eventStore: createEventStore(), everything-server.ts:2315). So on the missing
standalone stream the message is stored, not lost outright — but stored standalone
events are only replayed when a client reconnects with a Last-Event-ID header.
A first, fresh GET /mcp (no Last-Event-ID) does not replay them. Sequence:

  1. Client sends tools/call (POST).
  2. Tool handler runs and calls server.request(...) (no relatedRequestId) →
    transport targets _GET_stream.
  3. Standalone GET stream not connected yet → storeEvent, return (silent). The
    server's request() promise stays pending.
  4. Client opens GET /mcp fresh (no Last-Event-ID) → stored event is not replayed.
  5. Request is never delivered → 60s timeout → scenario fails.

Whether step 2 wins the race against step 4 depends on latency, which is why it's
intermittent and why inserting any proxy in front of the server makes it reproduce
far more often.

Proposed fix

Use the handler's extra.sendRequest(...) (which sets relatedRequestId to the
current tool-call id) instead of mcpServer.server.request(...) in both handlers.

-    async (args: { prompt: string }) => {
+    async (args: { prompt: string }, extra) => {
       try {
-        const result = await mcpServer.server.request(
+        const result = await extra.sendRequest(
           {
             method: 'sampling/createMessage',
             params: { messages: [...], maxTokens: 100 }
           },
           z.object({ method: z.literal('sampling/createMessage') }).passthrough() as any
         );
-    async (args: { message: string }) => {
+    async (args: { message: string }, extra) => {
       try {
-        const result = await mcpServer.server.request(
+        const result = await extra.sendRequest(
           {
             method: 'elicitation/create',
             params: { message: args.message, requestedSchema: {...} }
           },
           ElicitResultSchema
         );

(The two SEP-specific elicitation handlers that use the same direct
mcpServer.server.request(...) pattern should be switched to extra.sendRequest
as well, for the same reason.)

Repro note

Run the tools-call-sampling server scenario against everything-server.ts with a
small latency shim (e.g. a transparent HTTP proxy) in front of the server so the
tool handler executes before the client's standalone GET /mcp stream is
established. The sampling request is dropped on the not-yet-connected standalone
stream and the scenario fails with a ~60s request timeout. Running directly against
the server usually passes, since the GET stream is typically up in time — hence the
flakiness. Applying the extra.sendRequest patch makes it deterministic because the
request rides the already-open tools/call response stream.

Spec note

Emitting server→client requests on the standalone GET stream is technically permitted
by the spec, but it is fragile: it assumes the client has already opened the standalone
GET stream before the tool handler runs, which the protocol does not guarantee.
Binding server→client requests to the originating request's stream via
relatedRequestId (what extra.sendRequest does) is the robust pattern and is what a
reference server should model.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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