Repository navigation
Promise/async handling causes unhandled rejections #392
Description
Activity
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
unhandledRejectionlistenerprocess.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- addedneeds confirmationNeeds confirmation that the PR is actually required or needed.Needs confirmation that the PR is actually required or needed.
on Oct 27, 2025 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.
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:
typescript-sdk/src/shared/protocol.ts
Line 356 in e74a358
.catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); Will just disappear if there's no onerror handler here:
typescript-sdk/src/shared/protocol.ts
Line 323 in e74a358
this.onerror?.(error); And all the messages are still just processed without waiting at all:
typescript-sdk/src/server/streamableHttp.ts
Lines 569 to 572 in e74a358
// 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
- added a commit that references this issue
on Apr 5, 2026 bug present on both
mainandv1.x. when a transportsend()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 noonerrorhandler is registered — which is the default. the client hangs until its own timeout, and there is no log orunhandledRejectionevent 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.errorfallback 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-673onv1.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
client.ping()→ client sends{"jsonrpc":"2.0","method":"ping","id":1}via in-memory transport- server
_onmessagedispatches to_onrequest _onrequestcalls the ping handler, gets a result, then callscapturedTransport.send(response)at line 660send()throws ("SIMULATED: transport write failed")- the
.catchat line 660 callsthis._onerror(new Error("Failed to send response: ...")) _onerror(line 524-525):this.onerror?.(error)—onerrorisundefined, optional chain short-circuits, error is dropped- no response ever reaches the client; client times out
present on both
main(b8886e7) andv1.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.sendto always throw, fires a ping message viatransport.onmessage, and assertsconsole.errorwas called with the error — covering the case where noonerrorhandler is set (the default).- addedready for workEnough information for someone to start working onEnough information for someone to start working onP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable featurefix proposedBot has a verified fix diff in the commentBot has a verified fix diff in the commentand removedneeds confirmationNeeds confirmation that the PR is actually required or needed.Needs confirmation that the PR is actually required or needed.
on Apr 17, 2026
Describe the bug
If an error occurs while sending an error response in a request
handler()it remains unhandled if there's noonerrorhandler 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 anunhandledRejectionevent 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
onerrorhandler 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
_onrequesthandler 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.errorwould 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.