Skip to content

fix(server): close StdioServerTransport when stdin ends or closes#2494

Draft
claude[bot] wants to merge 1 commit into
mainfrom
claude/fix-2002-stdin-eof-close
Draft

fix(server): close StdioServerTransport when stdin ends or closes#2494
claude[bot] wants to merge 1 commit into
mainfrom
claude/fix-2002-stdin-eof-close

Conversation

@claude

@claude claude Bot commented Jul 14, 2026

Copy link
Copy Markdown

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. StdioServerTransport only listens for data and error on stdin, so stdin EOF is silently ignored: onclose never 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 onclose exactly once. That propagates through the existing close chain — Protocol._onclose for hand-wired Server/McpServer, and wire.onclose teardown for serveStdio — 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 _onstdinclose handler (same arrow-function pattern as _onstdouterror) registered for stdin end and close in start(), removed in close(). It calls this.close(), which is idempotent via the existing _closed guard, so onclose fires exactly once even when end and close both fire or close() is also called explicitly. Class JSDoc documents the behavior.
  • packages/server/test/server/stdio.test.ts — four new unit tests: onclose fires on stdin end (using autoDestroy: false, emitClose: false so the end listener is proven independently of close); onclose fires on stdin destroy (close); onclose fires exactly once when close() follows stdin EOF; messages that arrived before EOF are still delivered.
  • test/integration/test/processCleanup.test.ts + new fixture serverWithKeepAlive.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 in onclose, 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 the sendLoggingMessage rejection 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 onclose handler before letting the process drain; anything more exotic can pass a custom Readable.

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/integration processCleanup.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 clean

Fixes #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 main with unit + end-to-end regression coverage and the fixture cleanup.

🤖 Generated with Claude Code

https://claude.ai/code/session_016gag9FFr6udcsF8qvMvMV8


Generated by Claude Code

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-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 92f66b9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core-internal Patch

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2494

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2494

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2494

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2494

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2494

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2494

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2494

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2494

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2494

commit: 92f66b9

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: StdioServerTransport doesn't handle stdin close/end — causes zombie process accumulation

1 participant