Skip to content

Promise/async handling causes unhandled rejections #392

Description

@mhart

Describe the bug

If an error occurs while sending an error response in a request handler() it remains unhandled if there's no onerror handler on the protocol (which is the default)

This means that nothing is returned to the client and the promise rejection goes unhandled. On Cloudflare Workers, this will result in an error like this: A hanging Promise was canceled. This happens when the worker runtime is waiting for a Promise from JavaScript to resolve, but has detected that the Promise cannot possibly ever resolve because all code and events related to the Promise's I/O context have already finished., and on Node.js, I suspect this would result in an unhandledRejection event though I haven't confirmed this – it may just be that the request stays open and eventually times out.

The problem is it's very hard to know exactly what's happened (or even that adding an onerror handler may help)

Expected behavior

Currently the protocol fires off Promises without awaiting them. Messages are processed in a loop without waiting, which means they all get processed in parallel, with no limits or waiting for execution. And the _onrequest handler creates a Promise without awaiting or returning it.

This kind of event handling becomes difficult to manage unless all event handlers ultimately resolve (including error handling). In the case of unhandled rejections, it's often not clear where the rejection happened, if it's even logged / managed at all – the error could just disappear into the ether if there's no error handler.

It also means it's very easy for errors to be handled multiple times, and if each handler writes a message on the transport, you then end up with Cannot write headers after they are sent to the client.

Ideally, install default error handlers that can handle an error occurring anywhere and won't leave unhandled promises/rejections. Having a default handler that at least does console.error would ensure that users should see the error, even if it's not handled gracefully.

Longer term I'd love to see this refactored to use async/await instead of events – it should be much easier to reason about, much easier to handle concurrency and flow control (eg message processing), and much easier to ensure errors or responses aren't sent over the wire multiple times.

Activity

  1. PriyanshuUpadhyay commented on Apr 29, 2025

    @PriyanshuUpadhyay

    We also observed something similar where an mcp server threw an error during mcp.callTool()

    Now the error seemed to be propagated correctly to the outer try catch block but simultaneously it seemed to reject it internally and that rejection was unhandled as it was caught by unhandledRejection listener

    process.on("unhandledRejection", (reason, promise) => {
            ...
    	logger.error(
    		{
    			reason,
    			promise,
    		},
    		`REJECTION_UNHANDLED: ${reason} at ${promise}`,
    	);
            ...
    });

    We tried replicating it ourselves but could not do so during testing on our local machines. Let me know if you need any additional information

    Versions

    node: v20
    @modelcontextprotocol/sdk: 1.9.0
    
  2. felixweinberger commented on Oct 27, 2025

    @felixweinberger
    Contributor

    Hi thanks for reporting this, checking whether this is still an issue for you? We're working through a backlog of issues on the TS SDK to make sure we focus our time appropriately.

  3. mhart commented on Oct 27, 2025

    @mhart
    ContributorAuthor

    Hi thanks for reporting this, checking whether this is still an issue for you? We're working through a backlog of issues on the TS SDK to make sure we focus our time appropriately.

    It appears so. The protocol code in question hasn't changed since this issue was filed. Eg:

    An error handled here:

    .catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`)));

    Will just disappear if there's no onerror handler here:

    this.onerror?.(error);

    And all the messages are still just processed without waiting at all:

    // handle each message
    for (const message of messages) {
    this.onmessage?.(message, { authInfo, requestInfo });
    }

    So Promises are still being fired off left right and centre with no coordination

  4. mcp-claude commented on Apr 17, 2026

    @mcp-claude

    bug present on both main and v1.x. when a transport send() fails after a request handler runs (e.g. connection dropped between receiving the request and sending the response), the error routes into _onerror() and disappears silently if no onerror handler is registered — which is the default. the client hangs until its own timeout, and there is no log or unhandledRejection event to indicate what went wrong.

    root cause: packages/core/src/shared/protocol.ts:524-525:

    private _onerror(error: Error): void {
        this.onerror?.(error);   // ← no default fallback
    }

    workaround: register server.onerror = (err) => console.error(err) — this surfaces the errors correctly (confirmed in test 2 below).

    fix: add a console.error fallback so the default behavior is safe:

    private _onerror(error: Error): void {
        if (this.onerror) {
            this.onerror(error);
        } else {
            console.error(error);
        }
    }

    same change needed at src/shared/protocol.ts:672-673 on v1.x.

    repro script (repro.ts — run from repo root after building packages)
    import { Server } from "./packages/server/dist/index.mjs";
    import { Client } from "./packages/client/dist/index.mjs";
    import { InMemoryTransport } from "./packages/server/node_modules/@modelcontextprotocol/core/dist/index.mjs";
    
    let unhandledRejections: string[] = [];
    process.on("unhandledRejection", (reason) => { unhandledRejections.push(String(reason)); });
    
    async function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); }
    
    async function main() {
      console.log("=== Test 1: transport send() failure — no onerror handler set (default) ===\n");
      {
        const server = new Server({ name: "test", version: "1.0" });
        const [clientTransport, serverTransport] = (InMemoryTransport as any).createLinkedPair();
        let msgCount = 0;
        const origSend = serverTransport.send.bind(serverTransport);
        serverTransport.send = async (msg: unknown) => {
          msgCount++;
          if (msgCount > 1) throw new Error("SIMULATED: transport write failed");
          return origSend(msg);
        };
        console.log(`server.onerror is set by default: ${typeof (server as any).onerror === "function"}`);
        await server.connect(serverTransport);
        const client = new Client({ name: "test-client", version: "1.0" });
        await client.connect(clientTransport);
        try {
          await Promise.race([
            client.ping(),
            sleep(800).then(() => { throw new Error("TIMEOUT: response never arrived (client hung)"); }),
          ]);
        } catch (e: unknown) {
          console.log(`Client observed: ${(e as Error).message}`);
        }
        await sleep(300);
        console.log(unhandledRejections.length > 0
          ? `UNHANDLED REJECTIONS: ${unhandledRejections}`
          : "No unhandledRejection fired — error silently swallowed by _onerror (BUG)");
        await server.close();
      }
    
      console.log("\n=== Test 2: same scenario with onerror handler registered ===\n");
      {
        const server = new Server({ name: "test2", version: "1.0" });
        const caughtErrors: string[] = [];
        (server as any).onerror = (err: Error) => caughtErrors.push(err.message);
        const [clientTransport, serverTransport] = (InMemoryTransport as any).createLinkedPair();
        let msgCount2 = 0;
        const origSend2 = serverTransport.send.bind(serverTransport);
        serverTransport.send = async (msg: unknown) => {
          msgCount2++;
          if (msgCount2 > 1) throw new Error("SIMULATED: transport write failed");
          return origSend2(msg);
        };
        await server.connect(serverTransport);
        const client2 = new Client({ name: "c2", version: "1.0" });
        await client2.connect(clientTransport);
        try {
          await Promise.race([client2.ping(), sleep(800).then(() => { throw new Error("TIMEOUT"); })]);
        } catch (_) {}
        await sleep(300);
        console.log(caughtErrors.length > 0
          ? `onerror handler received ${caughtErrors.length} error(s): ${caughtErrors}`
          : "onerror handler was NOT called (unexpected)");
        await server.close();
      }
    }
    
    main().catch((e) => { console.error("Fatal:", e); process.exit(1); });
    command + output
    $ npx tsx repro.ts
    === Test 1: transport send() failure — no onerror handler set (default) ===
    
    server.onerror is set by default: false
    Sending ping — server will fail to send response...
    Client observed: TIMEOUT: response never arrived (client hung)
    
    No unhandledRejection event fired.
    → BUG CONFIRMED: transport send() failure silently swallowed by _onerror.
      Error never reached console.error, a logger, or unhandledRejection.
    
    === Test 2: same scenario with onerror handler registered ===
    
    onerror handler received 1 error(s):
      Failed to send response: Error: SIMULATED: transport write failed
    
    → With onerror registered, errors ARE surfaced.
      Without it (Test 1), identical errors disappear silently.
    
    code path
    1. client.ping() → client sends {"jsonrpc":"2.0","method":"ping","id":1} via in-memory transport
    2. server _onmessage dispatches to _onrequest
    3. _onrequest calls the ping handler, gets a result, then calls capturedTransport.send(response) at line 660
    4. send() throws ("SIMULATED: transport write failed")
    5. the .catch at line 660 calls this._onerror(new Error("Failed to send response: ..."))
    6. _onerror (line 524-525): this.onerror?.(error) — onerror is undefined, optional chain short-circuits, error is dropped
    7. no response ever reaches the client; client times out

    present on both main (b8886e7) and v1.x (bf1e022) — needs fix on both.

    suggested fix
    // packages/core/src/shared/protocol.ts
      private _onerror(error: Error): void {
    -     this.onerror?.(error);
    +     if (this.onerror) {
    +         this.onerror(error);
    +     } else {
    +         console.error(error);
    +     }
      }

    test to verify: registers a ping handler on a protocol instance, stubs transport.send to always throw, fires a ping message via transport.onmessage, and asserts console.error was called with the error — covering the case where no onerror handler is set (the default).

  5. added
    ready for workEnough information for someone to start working on
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    fix proposedBot has a verified fix diff in the comment
    and removed
    needs confirmationNeeds confirmation that the PR is actually required or needed.
    on Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions