fix: prevent unhandled rejections on close and detect stdin EOF#1853
fix: prevent unhandled rejections on close and detect stdin EOF#1853EtanHey wants to merge 3 commits intomodelcontextprotocol:mainfrom
Conversation
Two fixes for the "Connection closed" crash (MCP error -32000): 1. Protocol._onclose() now defers pending response handler rejections to the next microtask via Promise.resolve().then(), giving callers time to attach .catch() handlers. This prevents Node.js from triggering unhandled promise rejections when the transport closes unexpectedly while requests are in-flight. 2. StdioServerTransport now listens for the stdin 'end' event (EOF) and triggers a clean close(). Previously, when the client process exited, the server had no way to detect the disconnection, leaving it running indefinitely with pending requests that would never resolve. Together these fixes address the crash reported in modelcontextprotocol#1049 where stdio-based MCP servers would die with "MCP error -32000: Connection closed" unhandled rejections when the client process exits. Also addresses the root cause described in modelcontextprotocol#392 (promise/async handling causes unhandled rejections). Fixes modelcontextprotocol#1049 Refs modelcontextprotocol#392
🦋 Changeset detectedLatest commit: 10c6e6c The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
The handler variable using PromiseRejectionEvent was dead code — the
test already uses the Node.js process.on('unhandledRejection') handler.
Removing it fixes the TS2304 type error in CI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Note: I noticed PR #1814 also touches stdin EOF detection and |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
travisbreaks
left a comment
There was a problem hiding this comment.
Good fix for a real production issue. The microtask deferral pattern and stdin EOF detection are both needed. Notes:
1. Microtask timing changes observable behavior
The Promise.resolve().then() deferral means pending request rejections now happen asynchronously after _onclose() returns. Any code that previously assumed rejection was synchronous with close will now have a subtle ordering difference. The AbortController.abort() calls in the same finally block still run synchronously, so there's a timing gap between abort and rejection. Worth documenting this in the changeset.
2. Error swallowing in _onend
this.close().catch(() => {
// Ignore errors during close
});Silent error swallowing is understandable on the shutdown path, but routing to _onerror instead of () => {} would preserve observability for server authors who have error handlers configured:
this.close().catch((err) => {
this.onerror?.(err instanceof Error ? err : new Error(String(err)));
});3. Timer-based microtask flush in tests
The setTimeout(resolve, 50) approach works but is technically racy under heavy CI load. await Promise.resolve(); await Promise.resolve(); (chained) would be a more deterministic microtask flush. Minor, but avoids flaky test failures.
4. Missing test for handler-throws path
The try/catch in the microtask routes handler errors to _onerror:
try {
handler(error);
} catch (handlerError) {
this._onerror(handlerError instanceof Error ? handlerError : new Error(String(handlerError)));
}But no test exercises this path (where handler(error) itself throws). A test that installs a throwing handler and verifies _onerror receives the thrown error would complete the coverage.
The 5 tests are well-structured, especially the idempotent close test and the message-processing-before-EOF test. Good work.
Summary
Fixes two related bugs that together cause the "MCP error -32000: Connection closed" crash (#1049):
Defer pending response handler rejections in
Protocol._onclose()— When the transport closes with in-flight requests,_onclose()rejects all pending promises. Previously this happened synchronously, which could trigger unhandled promise rejections if callers hadn't attached.catch()handlers yet (especially on Node.js 24's strict unhandled-rejection behavior). Now rejections are deferred to the next microtask viaPromise.resolve().then(), giving callers time to attach handlers.Detect stdin EOF in
StdioServerTransport— The server transport only listened fordataanderrorevents on stdin, but neverend. When the client process exits, stdin emitsend(EOF), but the server had no listener for it — leaving the server running indefinitely with pending requests that would never resolve. NowStdioServerTransportlistens for theendevent and triggers a cleanclose().What
packages/core/src/shared/protocol.tshandler(error)calls in_onclose()to next microtask; wrap in try/catch routing toonerrorpackages/server/src/server/stdio.ts_onendhandler for stdinendevent; register/unregister instart()/close()Why
When a stdio-based MCP server's client process exits unexpectedly:
.catch()attached yet, Node.js triggersunhandledRejection--unhandled-rejections=throw), this kills the entire processThis crashes BrainLayer, VoiceLayer, and other MCP servers in production environments daily.
Test Plan
should reject pending requests with ConnectionClosed when transport closesshould not cause unhandled promise rejections when transport closes with pending requestsshould close transport when stdin emits end (EOF)should not fire onclose twice when stdin EOF followed by explicit close()should process remaining messages before closing on stdin EOFRelated
🤖 Generated with Claude Code