Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d4082fd
fix(stdio): add stdin close/end listeners to prevent zombie processes
ElliotDrel May 2, 2026
974caf7
style: run prettier on FastMCP.ts
ElliotDrel May 2, 2026
32bcdee
fix: address Copilot review — idempotency, listener cleanup, and stdi…
ElliotDrel May 2, 2026
fe6f3b4
fix: resolve ESLint sort-objects and no-unused-vars in stdio test
ElliotDrel May 2, 2026
3baf24f
fix(lint): remove unused params from stdinOffSpy mockImplementation
ElliotDrel May 2, 2026
6ef82e0
fix(lint): apply prettier formatting to stdio test file
ElliotDrel May 2, 2026
4003dff
fix(test): hoist vi.mock to module level to fix Vitest hoisting issue
ElliotDrel May 2, 2026
35685ff
fix(test): use fake timers to skip FastMCPSession capability retry loop
ElliotDrel May 2, 2026
c69e4d0
fix(test): use regular function in vi.fn mock so new StdioServerTrans…
ElliotDrel May 2, 2026
8ecdb87
test: add integration test for stdin-close zombie prevention
ElliotDrel May 2, 2026
536db8e
style: fix prettier formatting in stdio integration test
ElliotDrel May 3, 2026
fb4a76c
fix(lint): fix perfectionist ordering in stdio integration test
ElliotDrel May 3, 2026
801a6cb
fix(test): increase timeout for tsx cold-download in CI (60s ready, 9…
ElliotDrel May 3, 2026
0ea79e6
fix(test): add tsx devDep, use installed binary instead of npx cold-d…
ElliotDrel May 3, 2026
9adbbf5
fix: update pnpm-lock.yaml after adding tsx devDependency
ElliotDrel May 3, 2026
e0b453f
fix(test): use temp .ts file instead of --eval so ESM imports resolve…
ElliotDrel May 3, 2026
ab86420
style: fix prettier formatting in integration test
ElliotDrel May 3, 2026
19bdd1c
fix(lint): sort named imports, add comment to empty catch blocks
ElliotDrel May 3, 2026
21bcb71
revert: remove integration test and tsx devDep
ElliotDrel May 3, 2026
4de57ee
Merge branch 'main' into fix/stdin-close-zombie-process
punkpeye Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions src/FastMCP.stdio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { FastMCP } from "./FastMCP.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeFakeTransport() {
return {
close: vi.fn().mockResolvedValue(undefined),
onclose: undefined as (() => void) | undefined,
onerror: undefined as ((e: Error) => void) | undefined,
onmessage: undefined as ((msg: unknown) => void) | undefined,
send: vi.fn().mockResolvedValue(undefined),
start: vi.fn().mockResolvedValue(undefined),
};
}

// Module-level so the vi.mock factory (hoisted) can close over it.
// Each test reassigns this in beforeEach.
let fakeTransport: ReturnType<typeof makeFakeTransport>;

// Must use a regular function (not arrow) so `new StdioServerTransport()` works.
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
StdioServerTransport: vi.fn(function () {
return fakeTransport;
}),
}));

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

// session.connect() retries getClientCapabilities() 10×100ms (~1s real time).
// Give waitFor enough headroom beyond that.
const LISTENER_TIMEOUT = 3000;

describe("stdio stdin listener lifecycle", () => {
let stdinOnSpy: ReturnType<typeof vi.spyOn>;
let stdinOffSpy: ReturnType<typeof vi.spyOn>;
let stdinListeners: Map<string, (...args: unknown[]) => void>;

beforeEach(() => {
fakeTransport = makeFakeTransport();
stdinListeners = new Map();

stdinOnSpy = vi.spyOn(process.stdin, "on").mockImplementation(function (
event: string,
listener: (...args: unknown[]) => void,
) {
stdinListeners.set(event, listener);
return process.stdin;
});

stdinOffSpy = vi
.spyOn(process.stdin, "off")
.mockImplementation(function () {
return process.stdin;
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("registers 'close' and 'end' listeners after start({ transportType: 'stdio' })", async () => {
const server = new FastMCP({ name: "Test", version: "1.0.0" });
server.start({ transportType: "stdio" }).catch(() => {});

await vi.waitFor(
() => {
expect(stdinOnSpy).toHaveBeenCalledWith("close", expect.any(Function));
expect(stdinOnSpy).toHaveBeenCalledWith("end", expect.any(Function));
},
{ timeout: LISTENER_TIMEOUT },
);
});

it("calls transport.close() exactly once when 'close' fires", async () => {
const server = new FastMCP({ name: "Test", version: "1.0.0" });
server.start({ transportType: "stdio" }).catch(() => {});

await vi.waitFor(
() => {
expect(stdinListeners.get("close")).toBeDefined();
},
{ timeout: LISTENER_TIMEOUT },
);

stdinListeners.get("close")!();
expect(fakeTransport.close).toHaveBeenCalledTimes(1);
});

it("does NOT call transport.close() a second time when 'end' fires after 'close' (idempotency)", async () => {
const server = new FastMCP({ name: "Test", version: "1.0.0" });
server.start({ transportType: "stdio" }).catch(() => {});

await vi.waitFor(
() => {
expect(stdinListeners.get("close")).toBeDefined();
expect(stdinListeners.get("end")).toBeDefined();
},
{ timeout: LISTENER_TIMEOUT },
);

stdinListeners.get("close")!();
stdinListeners.get("end")!();

expect(fakeTransport.close).toHaveBeenCalledTimes(1);
});

it("removes both listeners after the handler fires", async () => {
const server = new FastMCP({ name: "Test", version: "1.0.0" });
server.start({ transportType: "stdio" }).catch(() => {});

await vi.waitFor(
() => {
expect(stdinListeners.get("close")).toBeDefined();
},
{ timeout: LISTENER_TIMEOUT },
);

const closeListener = stdinListeners.get("close")!;
closeListener();

expect(stdinOffSpy).toHaveBeenCalledWith("close", closeListener);
expect(stdinOffSpy).toHaveBeenCalledWith("end", closeListener);
});
});
20 changes: 20 additions & 0 deletions src/FastMCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2876,6 +2876,22 @@ export class FastMCP<

await session.connect(transport);

// Belt-and-suspenders: detect when the MCP client closes its end of
// the stdin pipe and shut down the transport so the process doesn't
// linger as a zombie/orphan. The upstream SDK fix (PR #2003) handles
// this inside StdioServerTransport itself, but adding the listener here
// means older SDK versions are also protected.
let stdinClosed = false;
const onStdinClose = () => {
if (stdinClosed) return;
stdinClosed = true;
process.stdin.off("close", onStdinClose);
process.stdin.off("end", onStdinClose);
transport.close().catch(() => {});
};
process.stdin.on("close", onStdinClose);
process.stdin.on("end", onStdinClose);
Comment thread
ElliotDrel marked this conversation as resolved.
Comment thread
ElliotDrel marked this conversation as resolved.

this.#sessions.push(session);

session.once("error", () => {
Expand All @@ -2887,6 +2903,8 @@ export class FastMCP<
const originalOnClose = transport.onclose;

transport.onclose = () => {
process.stdin.off("close", onStdinClose);
process.stdin.off("end", onStdinClose);
this.#removeSession(session);

if (originalOnClose) {
Expand All @@ -2895,6 +2913,8 @@ export class FastMCP<
};
} else {
transport.onclose = () => {
process.stdin.off("close", onStdinClose);
process.stdin.off("end", onStdinClose);
this.#removeSession(session);
};
}
Expand Down
Loading