Skip to content

fix(stdio): add stdin close/end listeners to prevent zombie/orphan processes#265

Open
ElliotDrel wants to merge 19 commits intopunkpeye:mainfrom
ElliotDrel:fix/stdin-close-zombie-process
Open

fix(stdio): add stdin close/end listeners to prevent zombie/orphan processes#265
ElliotDrel wants to merge 19 commits intopunkpeye:mainfrom
ElliotDrel:fix/stdin-close-zombie-process

Conversation

@ElliotDrel
Copy link
Copy Markdown

Summary

Fixes #264.

When an MCP client closes its end of the stdin pipe (restart, crash, or normal shutdown), StdioServerTransport never fires a close event because the underlying @modelcontextprotocol/sdk only registers data and error listeners on process.stdin — not close or end. The FastMCP server process therefore lingers indefinitely as a zombie/orphan.

This PR adds belt-and-suspenders process.stdin listeners immediately after session.connect(transport) for the stdio transport path. When either close or end fires, transport.close() is called and the process exits cleanly.

What changed

src/FastMCP.ts — inside the if (config.transportType === "stdio") block, after await session.connect(transport):

const onStdinClose = () => { transport.close().catch(() => {}); };
process.stdin.on("close", onStdinClose);
process.stdin.on("end", onStdinClose);

Why here and not just rely on the SDK fix?

References

Test plan

  • Start a FastMCP stdio server as a child process
  • Destroy the child's stdin (child.stdin.destroy()) to simulate a client disconnect
  • Confirm the server process exits (previously it would hang)
  • Confirm existing tests still pass (npm test)

When an MCP client (e.g. Claude Code) closes its end of the stdin pipe,
StdioServerTransport does not detect the disconnect and the server
process lingers indefinitely as a zombie/orphan.

This adds belt-and-suspenders `process.stdin` close/end listeners
immediately after `session.connect(transport)` for the stdio transport
path. When either event fires, `transport.close()` is called so the
process exits cleanly.

The upstream SDK is tracking the same root cause in
modelcontextprotocol/typescript-sdk#2003. Adding the fix here means
FastMCP users are protected regardless of which SDK version they have
installed.
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

This PR addresses stdio-mode process leaks by ensuring FastMCP reacts when the MCP client closes its end of the stdin pipe, triggering transport shutdown so the server can terminate instead of lingering.

Changes:

  • Add process.stdin close and end listeners after session.connect(transport) in the stdio transport path.
  • On stdin shutdown, call transport.close() to trigger session/transport teardown.

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

Comment thread src/FastMCP.ts Outdated
Comment thread src/FastMCP.ts
Comment thread src/FastMCP.ts
ElliotDrel and others added 18 commits May 1, 2026 21:04
…o test

- Add stdinClosed guard to prevent double-close race when both close
  and end fire for the same shutdown
- Remove stdin listeners inside the handler (on first fire) and also in
  transport.onclose to prevent listener accumulation across start() calls
- Add src/FastMCP.stdio.test.ts with vitest tests verifying registration,
  single-fire idempotency, and listener removal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
vi.mock() is statically hoisted by Vitest, so factory closures cannot
reference test-body locals. Move fakeTransport to module scope and
assign it in beforeEach so the mock factory captures the right value.
session.connect() retries getClientCapabilities() up to 10x100ms before
resolving. Without fake timers, the test's single Promise.resolve() tick
was not enough for the stdin listeners to be registered.
…port() works

Arrow functions cannot be used as constructors. Switching to a regular
function in the vi.mock factory lets `new StdioServerTransport()` in
FastMCP.start() return fakeTransport correctly.

Also removes fake timer usage — the 10×100ms capability-retry loop in
session.connect() runs in real time (~1s), and vi.waitFor with a 3s
timeout waits it out cleanly.
Spawns a real FastMCP stdio server child process, destroys stdin to
simulate client disconnect, and asserts the child exits cleanly.
Regression test for punkpeye#264.
Three ESLint perfectionist violations:
- node:child_process import before vitest
- env before stdio in spawn options object
- null before number in union type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unit tests in FastMCP.stdio.test.ts already cover the fix.
The integration test consistently failed due to tsx --eval ESM incompatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

stdio transport: server becomes zombie/orphan process when MCP client closes stdin

2 participants