modelcontextprotocol / modelcontextprotocol/typescript-sdk

[v2] Client.listen() rejections escape as process-level unhandledRejection while the send is in flight; a parked send hangs listen() past its ack timeout

Open
#2,641 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

spec-2026-07-28 v2
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

What happened?

Client.listen() builds an explicit opening → open → closed state machine, but the promise it suspends on is attached too late. In packages/client/src/client/client.ts the flow is:

  1. opening is created (new Promise(...)) with resolveOpening/rejectOpening captured.
  2. The ack timer is armed and the caller-signal listener installed.
  3. await this.transport.send(jsonrpcRequest, ...)serially awaited.
  4. Only then const honored = await opening; attaches the rejection handler.

Every settle path that fires while step 3 is still pending — ack timeout, transport close, inbound server cancel, caller-signal abort, _resetConnectionState — calls rejectOpening(...) on a promise that has no handler attached, so the rejection surfaces as a process-level unhandledRejection that caller-side handling cannot prevent (a .catch() on the listen() promise does not help, because listen() itself is still suspended inside the send). In production this shows up as crashes under --unhandled-rejections=strict and noisy unhandledRejection events that no application code can own.

Two failure shapes compound it:

  • Slow send: any transport send that outlives the ack timeout (default 60s, or options.timeout) produces the escaped REQUEST_TIMEOUT rejection.
  • Parked send: StdioClientTransport.send() waits indefinitely on 'drain' when the child's stdin is backed up and ignores TransportSendOptions.requestSignal entirely (see the overlapping #2552), so the send never settles. Then listen() also hangs forever, even though its own ack timer already fired and rejected the (unobserved) opening promise.
What did you expect?

Every termination path should reject the promise listen() returns — a caller who wrote await client.listen(...) inside try/catch should observe the timeout/close error there, and nothing should reach process.on('unhandledRejection').

Code to reproduce

Self-contained against the published packages (@modelcontextprotocol/client@2.0.0); observed output below. The scripted server answers server/discover (modern era) and never acks the listen; the client transport's subscriptions/listen send parks forever, modelling the backpressured-stdio case.

// node repro.mjs — @modelcontextprotocol/client@2.0.0
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";

const unhandled = [];
process.on("unhandledRejection", (reason) => unhandled.push(reason));

const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = (message) => {
  if (message.method === "server/discover" && message.id !== undefined) {
    void serverTx.send({
      jsonrpc: "2.0",
      id: message.id,
      result: {
        resultType: "complete",
        supportedVersions: ["2026-07-28"],
        capabilities: { tools: { listChanged: true } },
        _meta: { "io.modelcontextprotocol/serverInfo": { name: "scripted", version: "1" } },
      },
    });
  }
};
await serverTx.start();

const client = new Client({ name: "c", version: "0.0.0" }, { versionNegotiation: { mode: "auto" } });
const originalSend = clientTx.send.bind(clientTx);
clientTx.send = (message, options) =>
  message?.method === "subscriptions/listen" ? new Promise(() => {}) : originalSend(message, options);
await client.connect(clientTx);

const listenOutcome = client.listen({ toolsListChanged: true }, { timeout: 200 }).then(
  () => "resolved (unexpected)",
  (err) => `rejected, caught by caller: ${err?.code ?? err?.message}`,
);
const outcome = await Promise.race([
  listenOutcome,
  new Promise((r) => setTimeout(() => r("still pending after 1s — listen() HUNG"), 1000)),
]);
console.log("listen():", outcome);
console.log("unhandledRejection count:", unhandled.length);
for (const u of unhandled) console.log("  escaped:", u?.code ?? String(u));

Output on 2.0.0 (same code paths present on main @ cc4b416):

listen(): still pending after 1s — listen() HUNG
unhandledRejection count: 1
  escaped: REQUEST_TIMEOUT

Both symptoms at once: the ack-timeout rejection escaped to the process level, and listen() never settled.

Suggested fix

Make opening the promise listen() suspends on, and stop serially awaiting the send: fire transport.send(...) with a .catch that routes the failure into the existing settle({ cause: 'remote', ... }) funnel (plus a try/catch for a synchronous throw). Then every settle path rejects a promise whose handler is already attached, and a send that never settles no longer blocks the ack timer from surfacing through listen() itself.

SDK version

@modelcontextprotocol/client@2.0.0 (repro above); same code on main @ cc4b416.

Area

Client


Generated by Claude Code

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/client/src/client/client.ts at Client.listen(), tracing the opening promise, ack timer, settle paths, and transport.send() call. Run the supplied reproduction against the parked-send scenario; done means listen() rejects with the timeout or close error and no rejection reaches process-level unhandledRejection.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.