diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42f7954..5cb7451 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: @@ -1164,111 +1164,133 @@ packages: resolution: {integrity: sha512-UsQD5fyLWm2Fe5CDM7VPYAo+UC7+2Px4Y+N3AcPh/LdZu23YcuGPegQly++XEVaC8XUTFVPscl5y5Cl1twEI4A==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.53.3': resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.42.0': resolution: {integrity: sha512-/i8NIrlgc/+4n1lnoWl1zgH7Uo0XK5xK3EDqVTf38KvyYgCU/Rm04+o1VvvzJZnVS5/cWSd07owkzcVasgfIkQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.53.3': resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.42.0': resolution: {integrity: sha512-eoujJFOvoIBjZEi9hJnXAbWg+Vo1Ov8n/0IKZZcPZ7JhBzxh2A+2NFyeMZIRkY9iwBvSjloKgcvnjTbGKHE44Q==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.53.3': resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.42.0': resolution: {integrity: sha512-/3NrcOWFSR7RQUQIuZQChLND36aTU9IYE4j+TB40VU78S+RA0IiqHR30oSh6P1S9f9/wVOenHQnacs/Byb824g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-musl@4.53.3': resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.3': resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loongarch64-gnu@4.42.0': resolution: {integrity: sha512-O8AplvIeavK5ABmZlKBq9/STdZlnQo7Sle0LLhVA7QT+CiGpNVe197/t8Aph9bhJqbDVGCHpY2i7QyfEDDStDg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.42.0': resolution: {integrity: sha512-6Qb66tbKVN7VyQrekhEzbHRxXXFFD8QKiFAwX5v9Xt6FiJ3BnCVBuyBxa2fkFGqxOCSGGYNejxd8ht+q5SnmtA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.3': resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.42.0': resolution: {integrity: sha512-KQETDSEBamQFvg/d8jajtRwLNBlGc3aKpaGiP/LvEbnmVUKlFta1vqJqTrvPtsYsfbE/DLg5CC9zyXRX3fnBiA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.3': resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.42.0': resolution: {integrity: sha512-qMvnyjcU37sCo/tuC+JqeDKSuukGAd+pVlRl/oyDbkvPJ3awk6G6ua7tyum02O3lI+fio+eM5wsVd66X0jQtxw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-musl@4.53.3': resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.42.0': resolution: {integrity: sha512-I2Y1ZUgTgU2RLddUHXTIgyrdOwljjkmcZ/VilvaEumtS3Fkuhbw4p4hgHc39Ypwvo2o7sBFNl2MquNvGCa55Iw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.53.3': resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.42.0': resolution: {integrity: sha512-Gfm6cV6mj3hCUY8TqWa63DB8Mx3NADoFwiJrMpoZ1uESbK8FQV3LXkhfry+8bOniq9pqY1OdsjFWNsSbfjPugw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.3': resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.42.0': resolution: {integrity: sha512-g86PF8YZ9GRqkdi0VoGlcDUb4rYtQKyTD1IVtxxN4Hpe7YqLBShA7oHMKU6oKTCi3uxwW4VkIGnOaH/El8de3w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-linux-x64-musl@4.53.3': resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.3': resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} @@ -2355,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==} @@ -3368,6 +3393,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'} @@ -3749,6 +3777,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'} @@ -5297,13 +5330,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: @@ -6302,6 +6335,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 @@ -6997,12 +7035,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: @@ -7164,6 +7203,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 @@ -7609,7 +7651,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 @@ -7620,7 +7662,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 @@ -7637,6 +7679,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: @@ -7741,7 +7791,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) @@ -7753,11 +7803,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 @@ -7774,7 +7825,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 d193d5d..acbb855 100644 --- a/src/FastMCP.ts +++ b/src/FastMCP.ts @@ -2620,6 +2620,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", () => { @@ -2631,6 +2647,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) { @@ -2639,6 +2657,8 @@ export class FastMCP< }; } else { transport.onclose = () => { + process.stdin.off("close", onStdinClose); + process.stdin.off("end", onStdinClose); this.#removeSession(session); }; }