Skip to content

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

Open
ElliotDrel wants to merge 2 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 2 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

⚠️ No Changeset found

Latest commit: a3a4e04

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a 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: a3a4e04

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

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