fix(server): close StdioServerTransport when stdin ends or closes#2494
Draft
claude[bot] wants to merge 1 commit into
Draft
fix(server): close StdioServerTransport when stdin ends or closes#2494claude[bot] wants to merge 1 commit into
claude[bot] wants to merge 1 commit into
Conversation
Per the MCP stdio binding, servers should exit promptly when their standard input is closed - stdin EOF is the primary graceful-shutdown signal, and on Windows (where no signal is delivered when the host goes away) the only reliable one. StdioServerTransport previously listened only for 'data' and 'error' on stdin, so when an MCP client hung up its end of the pipe (window closed, session restarted, host crashed) the transport never noticed: onclose never fired, nothing tore down, and server processes accumulated as zombies until killed by hand. The transport now attaches 'end' and 'close' listeners on stdin that close the transport (idempotently - onclose still fires exactly once if close() is also called), so Server/McpServer and serveStdio tear down through the existing onclose chain and a well-behaved server process exits naturally. The serverThatHangs fixture now swallows the sendLoggingMessage rejection its signal handler hits once the transport closes itself on stdin EOF; the fixture keeps hanging on purpose either way. Fixes #2002
🦋 Changeset detectedLatest commit: 92f66b9 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/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Felix Weinberger · Slack thread
Before / After
Before: when an MCP client hangs up its end of the stdio pipe — window closed, session restarted, host crashed — the server never notices.
StdioServerTransportonly listens fordataanderroron stdin, so stdin EOF is silently ignored:onclosenever fires, nothing tears down, and any server holding a keep-alive handle (timer, connection pool, watcher) lingers forever. In practice this means zombie MCP server processes accumulating with every client restart until someone kills them by hand.After: stdin reaching EOF (or being destroyed) closes the transport and fires
oncloseexactly once. That propagates through the existing close chain —Protocol._onclosefor hand-wiredServer/McpServer, andwire.oncloseteardown forserveStdio— so a well-behaved server releases its handles and the process exits naturally. This is what the MCP stdio binding asks for: servers "SHOULD exit promptly when their standard input is closed"; stdin EOF is the primary graceful-shutdown signal, and on Windows (where no signal is delivered when the parent goes away) the only reliable one.How
packages/server/src/server/stdio.ts— new_onstdinclosehandler (same arrow-function pattern as_onstdouterror) registered for stdinendandcloseinstart(), removed inclose(). It callsthis.close(), which is idempotent via the existing_closedguard, soonclosefires exactly once even whenendandcloseboth fire orclose()is also called explicitly. Class JSDoc documents the behavior.packages/server/test/server/stdio.test.ts— four new unit tests:onclosefires on stdinend(usingautoDestroy: false, emitClose: falseso theendlistener is proven independently ofclose);onclosefires on stdin destroy (close);onclosefires exactly once whenclose()follows stdin EOF; messages that arrived before EOF are still delivered.test/integration/test/processCleanup.test.ts+ new fixtureserverWithKeepAlive.ts— end-to-end regression test for the zombie itself: spawns a real SDK server child that holds a keep-alive interval and releases it inonclose, completes an initialize round-trip, closes the child's stdin (no signals), and asserts the process exits with code 0. Verified this test fails against the unfixed transport (child stays alive past the bound and has to be SIGKILLed) and passes with the fix.test/integration/test/__fixtures__/serverThatHangs.ts— the fixture's signal handler now swallows thesendLoggingMessagerejection it hits once the transport closes itself on stdin EOF (previously an unhandled rejection); the fixture intentionally keeps hanging either way, preserving what the signal-escalation test exercises..changeset/stdio-server-stdin-eof-close.md— patch changeset for@modelcontextprotocol/server.No opt-out flag. Considered and deliberately omitted: once stdin has ended no further input can ever arrive, so an open transport has nothing left to transport — there is no coherent use case for opting out, the spec words this as server behavior that SHOULD happen, and the community PR #2003 proposing the same two listeners drew no maintainer request for a flag. Servers that need to finish in-flight work can still do so in their
onclosehandler before letting the process drain; anything more exotic can pass a customReadable.Out of scope: the parent-PID watchdog for hosts that die without closing the pipe — that is PR #1712's lane and the broader remainder of #208.
Tests
pnpm --filter @modelcontextprotocol/server test— 450 passed (40 files)test/integrationprocessCleanup.test.ts— 4 passed (including the new e2e; confirmed it fails without the fix)pnpm --filter @modelcontextprotocol/server check(typecheck + eslint + prettier) — clean; touched integration files are eslint/prettier cleanFixes #2002. Also addresses the stdin-EOF part of #208 (self-termination when the host closes the pipe); the periodic parent-PID liveness check discussed there remains open and is intentionally not duplicated here. Credit to #2003, which proposed the same two-listener fix — this PR lands it against current
mainwith unit + end-to-end regression coverage and the fixture cleanup.🤖 Generated with Claude Code
https://claude.ai/code/session_016gag9FFr6udcsF8qvMvMV8
Generated by Claude Code