fix(stdio): add stdin close/end listeners to prevent zombie/orphan processes - #265
Conversation
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.
|
Hi @punkpeye — just checking in on this. The PR has been CI-green and merge-ready since May 3 (15 days). It's a minimal 3-line fix that adds |
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.
…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>
582d869 to
21bcb71
Compare
|
@punkpeye it would be great to fix that issue quickly |
|
🎉 This PR is included in version 4.8.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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)