diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0a7b8a..6df2824 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,7 +116,7 @@ importers: version: 25.0.2(typescript@5.9.3) tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -128,7 +128,7 @@ importers: version: 1.2.0(typescript@5.9.3) vitest: specifier: ^4.0.15 - version: 4.0.15(@types/node@24.10.1)(jiti@2.6.1) + version: 4.0.15(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0) packages: @@ -2377,6 +2377,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + git-log-parser@1.2.1: resolution: {integrity: sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==} @@ -3388,6 +3391,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rollup@4.42.0: resolution: {integrity: sha512-LW+Vse3BJPyGJGAJt1j8pWDKPd73QM8cRXYK1IxOBgL2AGLu7Xd2YOW0M2sLUBCkF5MshXXtMApyEAEzMVMsnw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3769,6 +3775,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel@0.0.6: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} @@ -5317,13 +5328,13 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.15(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1))': + '@vitest/mocker@4.0.15(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.15 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1) + vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0) '@vitest/pretty-format@4.0.15': dependencies: @@ -6322,6 +6333,11 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + optional: true + git-log-parser@1.2.1: dependencies: argv-formatter: 1.0.0 @@ -7015,12 +7031,13 @@ snapshots: mlly: 1.7.4 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.6 + tsx: 4.21.0 postcss@8.5.6: dependencies: @@ -7182,6 +7199,9 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: + optional: true + rollup@4.42.0: dependencies: '@types/estree': 1.0.7 @@ -7627,7 +7647,7 @@ snapshots: tsscmp@1.0.6: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.1) cac: 6.7.14 @@ -7638,7 +7658,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.42.0 source-map: 0.7.6 @@ -7655,6 +7675,14 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.1 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + optional: true + tunnel@0.0.6: {} type-check@0.4.0: @@ -7759,7 +7787,7 @@ snapshots: vary@1.1.2: {} - vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1): + vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -7771,11 +7799,12 @@ snapshots: '@types/node': 24.10.1 fsevents: 2.3.3 jiti: 2.6.1 + tsx: 4.21.0 - vitest@4.0.15(@types/node@24.10.1)(jiti@2.6.1): + vitest@4.0.15(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)) + '@vitest/mocker': 4.0.15(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.15 '@vitest/runner': 4.0.15 '@vitest/snapshot': 4.0.15 @@ -7792,7 +7821,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1) + vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.1 diff --git a/src/FastMCP.stdio.test.ts b/src/FastMCP.stdio.test.ts new file mode 100644 index 0000000..5c3ec72 --- /dev/null +++ b/src/FastMCP.stdio.test.ts @@ -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; + +// 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; + let stdinOffSpy: ReturnType; + let stdinListeners: Map 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); + }); +}); diff --git a/src/FastMCP.ts b/src/FastMCP.ts index f109d1d..beb768e 100644 --- a/src/FastMCP.ts +++ b/src/FastMCP.ts @@ -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); + this.#sessions.push(session); session.once("error", () => { @@ -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) { @@ -2895,6 +2913,8 @@ export class FastMCP< }; } else { transport.onclose = () => { + process.stdin.off("close", onStdinClose); + process.stdin.off("end", onStdinClose); this.#removeSession(session); }; }