fix(stdio): add stdin close/end listeners to prevent zombie/orphan processes#265
Open
ElliotDrel wants to merge 19 commits intopunkpeye:mainfrom
Open
fix(stdio): add stdin close/end listeners to prevent zombie/orphan processes#265ElliotDrel wants to merge 19 commits intopunkpeye:mainfrom
ElliotDrel wants to merge 19 commits intopunkpeye:mainfrom
Conversation
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.
There was a problem hiding this comment.
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.stdincloseandendlisteners aftersession.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.
…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>
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.
Summary
Fixes #264.
When an MCP client closes its end of the stdin pipe (restart, crash, or normal shutdown),
StdioServerTransportnever fires a close event because the underlying@modelcontextprotocol/sdkonly registersdataanderrorlisteners onprocess.stdin— notcloseorend. The FastMCP server process therefore lingers indefinitely as a zombie/orphan.This PR adds belt-and-suspenders
process.stdinlisteners immediately aftersession.connect(transport)for thestdiotransport path. When eithercloseorendfires,transport.close()is called and the process exits cleanly.What changed
src/FastMCP.ts— inside theif (config.transportType === "stdio")block, afterawait session.connect(transport):Why here and not just rely on the SDK fix?
transportreference and the session lifecycle.References
Test plan
child.stdin.destroy()) to simulate a client disconnectnpm test)