Skip to content

fix(server): exit when MCP client closes stdin pipe#2003

Open
ElliotDrel wants to merge 4 commits intomodelcontextprotocol:mainfrom
ElliotDrel:fix/stdio-server-stdin-close-exit
Open

fix(server): exit when MCP client closes stdin pipe#2003
ElliotDrel wants to merge 4 commits intomodelcontextprotocol:mainfrom
ElliotDrel:fix/stdio-server-stdin-close-exit

Conversation

@ElliotDrel
Copy link
Copy Markdown

Problem

StdioServerTransport listens for data and error on stdin, but not for close or end. When an MCP client (e.g. Claude Code) closes its window or restarts, it drops its end of the stdio pipe. The server process never detects this and keeps running indefinitely.

This causes unbounded zombie process accumulation — every time the client restarts, a new server is spawned, and the old one never dies. Observed in production: 37 orphaned processes consuming 26,000+ CPU-seconds, machine became unresponsive.

This is especially severe on Windows, where SIGTERM is not reliably delivered to child processes when a parent exits, making stdin close the only cross-platform shutdown signal.

Related to #1568 (which fixed stdout EPIPE) but stdin close was left unhandled.
Tracked in issue #2002.

Fix

Add a close listener on stdin in start() that calls this.close(), using the same arrow-function pattern as _onstdouterror for proper cleanup on close().

Tests

  • should fire onclose when stdin emits close
  • should fire onclose when stdin emits end
  • should not fire onclose twice when close() called after stdin close

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 1, 2026 01:16
@ElliotDrel ElliotDrel requested a review from a team as a code owner May 1, 2026 01:16
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 1, 2026

🦋 Changeset detected

Latest commit: 27f0874

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

This PR includes changesets to release 5 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/fastify Patch
@modelcontextprotocol/hono Patch
@modelcontextprotocol/node 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
Copy link
Copy Markdown

pkg-pr-new Bot commented May 1, 2026

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 27f0874

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes orphaned/zombie StdioServerTransport server processes by detecting when the MCP client disconnects from stdin and initiating transport shutdown.

Changes:

  • Add a stdin close listener in StdioServerTransport.start() that triggers close().
  • Remove the stdin close listener during StdioServerTransport.close() cleanup.
  • Add tests intended to assert onclose fires on stdin termination signals.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/server/src/server/stdio.ts Adds stdin close handling to trigger transport shutdown and cleans up the listener on close.
packages/server/test/server/stdio.test.ts Adds tests for stdin close/end behavior and ensuring onclose isn’t double-fired.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 91 to 96
// Remove our event listeners first
this._stdin.off('data', this._ondata);
this._stdin.off('error', this._onerror);
this._stdin.off('close', this._onstdinclose);
this._stdout.off('error', this._onstdouterror);

Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close() cleans up the newly added stdin close handler, but if you add an end handler for stdin (needed for reliable pipe-disconnect detection), it should also be removed here to avoid leaking listeners across start/close cycles.

Copilot uses AI. Check for mistakes.
Comment on lines +197 to +208
const server = new StdioServerTransport(input, output);
server.onerror = error => { throw error; };

let closeCount = 0;
server.onclose = () => { closeCount++; };

await server.start();
input.push(null); // signals end-of-stream

// Allow microtasks to flush
await new Promise(resolve => setTimeout(resolve, 0));

Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test claims to validate behavior on stdin end, but input.push(null) may also trigger a close event depending on stream settings, so it can pass even if the transport doesn't handle end at all. Make the test explicitly verify the end path (e.g., emit end without close, or construct a Readable where emitClose/autoDestroy won't emit close on end) so it fails unless an end listener is implemented.

Suggested change
const server = new StdioServerTransport(input, output);
server.onerror = error => { throw error; };
let closeCount = 0;
server.onclose = () => { closeCount++; };
await server.start();
input.push(null); // signals end-of-stream
// Allow microtasks to flush
await new Promise(resolve => setTimeout(resolve, 0));
const endOnlyInput = new Readable({
autoDestroy: false,
emitClose: false,
// We'll use endOnlyInput.push() instead.
read: () => {}
});
const server = new StdioServerTransport(endOnlyInput, output);
server.onerror = error => { throw error; };
let closeCount = 0;
let inputCloseCount = 0;
server.onclose = () => { closeCount++; };
endOnlyInput.on('close', () => { inputCloseCount++; });
await server.start();
endOnlyInput.push(null); // signals end-of-stream without emitting close
// Allow microtasks to flush
await new Promise(resolve => setTimeout(resolve, 0));
expect(inputCloseCount).toBe(0);

Copilot uses AI. Check for mistakes.
Comment on lines 63 to 67
this._started = true;
this._stdin.on('data', this._ondata);
this._stdin.on('error', this._onerror);
this._stdin.on('close', this._onstdinclose);
this._stdout.on('error', this._onstdouterror);
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start() only listens for stdin's close event, but a disconnected pipe commonly emits end (and close is not guaranteed for all Readable streams). To reliably shut down on client disconnect, also listen for stdin's end event and remove that listener in close() alongside the existing close cleanup.

Copilot uses AI. Check for mistakes.
@ElliotDrel
Copy link
Copy Markdown
Author

Related issues and PRs addressing similar stdio/process lifecycle issues:

Same root cause (stdin/process shutdown):

Related upstream fix:

Alternative/complementary approaches:

@ElliotDrel
Copy link
Copy Markdown
Author

Addressed all three Copilot review comments:

  • Added _onstdinend handler and registered end listener in start() alongside close
  • Removed end listener in close() cleanup to prevent listener leaks
  • Fixed the stdin end test to use autoDestroy: false, emitClose: false so push(null) fires end but not close — the test now fails unless the end listener is explicitly registered

@ElliotDrel
Copy link
Copy Markdown
Author

Added a changeset for patch-level bump on @modelcontextprotocol/server. This fixes a process lifecycle bug where server processes accumulate as zombies when the MCP client disconnects.

@ElliotDrel
Copy link
Copy Markdown
Author

All Copilot review feedback has been addressed in the latest commits:

  1. end event listener added — both _onstdinclose and _onstdinend handlers are registered in start() and cleaned up in close().
  2. Listener cleanup in close() — both close and end listeners are removed via .off() to prevent leaks across start/close cycles.
  3. Robust test for end path — uses Readable({ autoDestroy: false, emitClose: false }) to verify the end listener fires independently of close, plus an idempotency test ensuring onclose only fires once even when both events occur.

CI is all green. Ready for maintainer review.

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.

2 participants