From 896e9f387abf24c81ec756286a33ed8601aee3bf Mon Sep 17 00:00:00 2001 From: Johnny Santos Date: Mon, 28 Jul 2025 23:13:24 -0300 Subject: [PATCH 01/13] fix: ensure stdio closes server --- src/client/stdio.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/client/stdio.ts b/src/client/stdio.ts index d62a3aeb6..57ee8f6a5 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -210,8 +210,30 @@ export class StdioClientTransport implements Transport { } async close(): Promise { - this._abortController.abort(); - this._process = undefined; + if (this._process) { + const processToClose = this._process; + this._process = undefined; + + const closePromise = new Promise(resolve => { + processToClose.once('close', () => { + resolve(); + }); + }); + + this._abortController.abort(); + + // waits the underlying process to exit cleanly otherwise after 1s kills it + await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 1_000).unref())]); + + if (processToClose.exitCode === null) { + try { + processToClose.kill('SIGKILL'); + } catch { + // we did our best + } + } + } + this._readBuffer.clear(); } From dbaf1e35a94d7f3ae152d7e25ab376e1a5553903 Mon Sep 17 00:00:00 2001 From: Johnny Santos Date: Tue, 4 Nov 2025 21:22:10 -0300 Subject: [PATCH 02/13] chore: add test to client close Co-authored-by: TomasRup --- src/integration-tests/process-cleanup.test.ts | 2 +- src/integration-tests/server-that-hangs.js | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/integration-tests/server-that-hangs.js diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts index e90ec7e24..5a41f269a 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/process-cleanup.test.ts @@ -4,7 +4,7 @@ import { StdioServerTransport } from '../server/stdio.js'; describe('Process cleanup', () => { vi.setConfig({ testTimeout: 5000 }); // 5 second timeout - it('should exit cleanly after closing transport', async () => { + it('server should exit cleanly after closing transport', async () => { const server = new Server( { name: 'test-server', diff --git a/src/integration-tests/server-that-hangs.js b/src/integration-tests/server-that-hangs.js new file mode 100644 index 000000000..72ef77bfa --- /dev/null +++ b/src/integration-tests/server-that-hangs.js @@ -0,0 +1,21 @@ +import { setTimeout } from 'node:timers' +import process from 'node:process' +import { McpServer } from "../../dist/esm/server/mcp.js"; +import { StdioServerTransport } from "../../dist/esm/server/stdio.js"; + +const transport = new StdioServerTransport(); + +const server = new McpServer({ + name: "server-that-hangs", + version: "1.0.0" +}); + +await server.connect(transport); + +const doNotExitImmediately = async () => { + setTimeout(() => process.exit(0), 30 * 1000); +}; + +process.stdin.on('close', doNotExitImmediately); +process.on('SIGINT', doNotExitImmediately); +process.on('SIGTERM', doNotExitImmediately); From 50434b7ac722f9b6aea20f60f8321f3b3147061b Mon Sep 17 00:00:00 2001 From: Johnny Santos Date: Mon, 28 Jul 2025 23:30:48 -0300 Subject: [PATCH 03/13] chore: avoid multiple calls to onclose when terminating --- src/client/stdio.ts | 1 - src/integration-tests/process-cleanup.test.ts | 28 +++++++++++++++++++ src/integration-tests/test-server.js | 11 ++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/integration-tests/test-server.js diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 57ee8f6a5..8f041703d 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -134,7 +134,6 @@ export class StdioClientTransport implements Transport { this._process.on('error', error => { if (error.name === 'AbortError') { // Expected when close() is called. - this.onclose?.(); return; } diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts index 5a41f269a..0fd1b697a 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/process-cleanup.test.ts @@ -1,3 +1,5 @@ +import { Client } from '../client/index.js'; +import { StdioClientTransport } from '../client/stdio.js'; import { Server } from '../server/index.js'; import { StdioServerTransport } from '../server/stdio.js'; @@ -25,4 +27,30 @@ describe('Process cleanup', () => { // The test runner will fail if the process hangs expect(true).toBe(true); }); + + it('onclose should be called exactly once', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StdioClientTransport({ + command: process.argv0, + args: ['test-server.js'], + cwd: __dirname + }); + + let onCloseWasCalled = 0; + client.onclose = () => { + onCloseWasCalled++; + }; + + await client.connect(transport); + await client.close(); + + // A short delay to allow the close event to propagate + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onCloseWasCalled).toBe(1); + }); }); diff --git a/src/integration-tests/test-server.js b/src/integration-tests/test-server.js new file mode 100644 index 000000000..3f3f2243c --- /dev/null +++ b/src/integration-tests/test-server.js @@ -0,0 +1,11 @@ +import { McpServer } from "../../dist/esm/server/mcp.js"; +import { StdioServerTransport } from "../../dist/esm/server/stdio.js"; + +const transport = new StdioServerTransport(); + +const server = new McpServer({ + name: "test-server", + version: "1.0.0", +}); + +await server.connect(transport); From e23c1f89f98b5ed16e6e7e6aaaa0cd75e07613ef Mon Sep 17 00:00:00 2001 From: Johnny Santos Date: Tue, 4 Nov 2025 21:13:13 -0300 Subject: [PATCH 04/13] chore: close stdin before sending sigkill --- src/client/stdio.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 8f041703d..f4a243d35 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -225,6 +225,12 @@ export class StdioClientTransport implements Transport { await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 1_000).unref())]); if (processToClose.exitCode === null) { + try { + processToClose.stdin?.end(); + } catch { + // ignore errors in trying to close stdin + } + try { processToClose.kill('SIGKILL'); } catch { From 0b38067ee2892133136f3093f174537fd8fdad1d Mon Sep 17 00:00:00 2001 From: Johnny Santos Date: Tue, 4 Nov 2025 21:53:00 -0300 Subject: [PATCH 05/13] chore: add mock stream cleanup Ensure proper disposal of mock streams to prevent resource leaks and avoid process hangs. --- src/integration-tests/process-cleanup.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts index 0fd1b697a..4ea77c4b7 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/process-cleanup.test.ts @@ -1,3 +1,4 @@ +import { Readable, Writable } from 'stream'; import { Client } from '../client/index.js'; import { StdioClientTransport } from '../client/stdio.js'; import { Server } from '../server/index.js'; @@ -17,12 +18,28 @@ describe('Process cleanup', () => { } ); - const transport = new StdioServerTransport(); + const mockReadable = new Readable({ + read() { + this.push(null); // signal EOF + } + }), + mockWritable = new Writable({ + write(chunk, encoding, callback) { + callback(); + } + }); + + // Attach mock streams to process for the server transport + const transport = new StdioServerTransport(mockReadable, mockWritable); await server.connect(transport); // Close the transport await transport.close(); + // ensure a proper disposal mock streams + mockReadable.destroy(); + mockWritable.destroy(); + // If we reach here without hanging, the test passes // The test runner will fail if the process hangs expect(true).toBe(true); From b93d56466f10bb86b8f8482a762f654b1c5052df Mon Sep 17 00:00:00 2001 From: Johnny Santos Date: Mon, 17 Nov 2025 16:25:24 -0300 Subject: [PATCH 06/13] chore: lint --- src/integration-tests/process-cleanup.test.ts | 2 +- src/integration-tests/server-that-hangs.js | 14 +++++++------- src/integration-tests/test-server.js | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts index 4ea77c4b7..7fa8c39b0 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/process-cleanup.test.ts @@ -1,4 +1,4 @@ -import { Readable, Writable } from 'stream'; +import { Readable, Writable } from 'node:stream'; import { Client } from '../client/index.js'; import { StdioClientTransport } from '../client/stdio.js'; import { Server } from '../server/index.js'; diff --git a/src/integration-tests/server-that-hangs.js b/src/integration-tests/server-that-hangs.js index 72ef77bfa..b229835b2 100644 --- a/src/integration-tests/server-that-hangs.js +++ b/src/integration-tests/server-that-hangs.js @@ -1,19 +1,19 @@ -import { setTimeout } from 'node:timers' -import process from 'node:process' -import { McpServer } from "../../dist/esm/server/mcp.js"; -import { StdioServerTransport } from "../../dist/esm/server/stdio.js"; +import { setTimeout } from 'node:timers'; +import process from 'node:process'; +import { McpServer } from '../../dist/esm/server/mcp.js'; +import { StdioServerTransport } from '../../dist/esm/server/stdio.js'; const transport = new StdioServerTransport(); const server = new McpServer({ - name: "server-that-hangs", - version: "1.0.0" + name: 'server-that-hangs', + version: '1.0.0' }); await server.connect(transport); const doNotExitImmediately = async () => { - setTimeout(() => process.exit(0), 30 * 1000); + setTimeout(() => process.exit(0), 30 * 1000); }; process.stdin.on('close', doNotExitImmediately); diff --git a/src/integration-tests/test-server.js b/src/integration-tests/test-server.js index 3f3f2243c..8c68fc325 100644 --- a/src/integration-tests/test-server.js +++ b/src/integration-tests/test-server.js @@ -1,11 +1,11 @@ -import { McpServer } from "../../dist/esm/server/mcp.js"; -import { StdioServerTransport } from "../../dist/esm/server/stdio.js"; +import { McpServer } from '../../dist/esm/server/mcp.js'; +import { StdioServerTransport } from '../../dist/esm/server/stdio.js'; const transport = new StdioServerTransport(); const server = new McpServer({ - name: "test-server", - version: "1.0.0", + name: 'test-server', + version: '1.0.0' }); await server.connect(transport); From a8f1a334c740d0991b18121f514ff8995b81aa87 Mon Sep 17 00:00:00 2001 From: Johnny Santos Date: Fri, 28 Nov 2025 12:02:11 -0300 Subject: [PATCH 07/13] fix: use proper initialization for servers in tests - Make test servers use ts and init them with tsx - Add test to check if hanging server actually gets killed --- src/integration-tests/process-cleanup.test.ts | 43 ++++++++++++++++-- src/integration-tests/server-that-hangs.js | 21 --------- src/integration-tests/server-that-hangs.ts | 45 +++++++++++++++++++ src/integration-tests/test-server.js | 11 ----- src/integration-tests/test-server.ts | 19 ++++++++ 5 files changed, 104 insertions(+), 35 deletions(-) delete mode 100644 src/integration-tests/server-that-hangs.js create mode 100644 src/integration-tests/server-that-hangs.ts delete mode 100644 src/integration-tests/test-server.js create mode 100644 src/integration-tests/test-server.ts diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts index 7fa8c39b0..1a5b9a35b 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/process-cleanup.test.ts @@ -3,6 +3,7 @@ import { Client } from '../client/index.js'; import { StdioClientTransport } from '../client/stdio.js'; import { Server } from '../server/index.js'; import { StdioServerTransport } from '../server/stdio.js'; +import { LoggingMessageNotificationSchema } from '../types.js'; describe('Process cleanup', () => { vi.setConfig({ testTimeout: 5000 }); // 5 second timeout @@ -52,17 +53,18 @@ describe('Process cleanup', () => { }); const transport = new StdioClientTransport({ - command: process.argv0, - args: ['test-server.js'], + command: 'npm', + args: ['exec', 'tsx', 'test-server.ts'], cwd: __dirname }); + await client.connect(transport); + let onCloseWasCalled = 0; client.onclose = () => { onCloseWasCalled++; }; - await client.connect(transport); await client.close(); // A short delay to allow the close event to propagate @@ -70,4 +72,39 @@ describe('Process cleanup', () => { expect(onCloseWasCalled).toBe(1); }); + + it('should exit cleanly for a server that hangs', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StdioClientTransport({ + command: 'npm', + args: ['exec', 'tsx', 'server-that-hangs.ts'], + cwd: __dirname + }); + + await client.connect(transport); + await client.setLoggingLevel('debug'); + client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + console.debug('server log: ' + notification.params.data); + }); + const serverPid = transport.pid!; + + await client.close(); + + // A short delay to allow the close event to propagate + await new Promise(resolve => setTimeout(resolve, 50)); + + try { + process.kill(serverPid, 9); + throw new Error('Expected server to be dead but it is alive'); + } catch (err: unknown) { + // 'ESRCH' the process doesn't exist + if (err && typeof err === 'object' && 'code' in err && err.code === 'ESRCH') { + // success + } else throw err; + } + }); }); diff --git a/src/integration-tests/server-that-hangs.js b/src/integration-tests/server-that-hangs.js deleted file mode 100644 index b229835b2..000000000 --- a/src/integration-tests/server-that-hangs.js +++ /dev/null @@ -1,21 +0,0 @@ -import { setTimeout } from 'node:timers'; -import process from 'node:process'; -import { McpServer } from '../../dist/esm/server/mcp.js'; -import { StdioServerTransport } from '../../dist/esm/server/stdio.js'; - -const transport = new StdioServerTransport(); - -const server = new McpServer({ - name: 'server-that-hangs', - version: '1.0.0' -}); - -await server.connect(transport); - -const doNotExitImmediately = async () => { - setTimeout(() => process.exit(0), 30 * 1000); -}; - -process.stdin.on('close', doNotExitImmediately); -process.on('SIGINT', doNotExitImmediately); -process.on('SIGTERM', doNotExitImmediately); diff --git a/src/integration-tests/server-that-hangs.ts b/src/integration-tests/server-that-hangs.ts new file mode 100644 index 000000000..7f82ac3f7 --- /dev/null +++ b/src/integration-tests/server-that-hangs.ts @@ -0,0 +1,45 @@ +import { setTimeout } from 'node:timers'; +import process from 'node:process'; +import { McpServer } from '../server/mcp.js'; +import { StdioServerTransport } from '../server/stdio.js'; + +const transport = new StdioServerTransport(); + +const server = new McpServer( + { + name: 'server-that-hangs', + title: 'Test Server that hangs', + version: '1.0.0' + }, + { + capabilities: { + logging: {} + } + } +); + +await server.connect(transport); + +const doNotExitImmediately = async (signal: NodeJS.Signals) => { + await server.sendLoggingMessage({ + level: 'debug', + data: `received signal ${signal}` + }); + setTimeout(() => process.exit(0), 30 * 1000); +}; + +transport.onclose = () => { + server.sendLoggingMessage({ + level: 'debug', + data: 'transport: onclose called. This should never happen' + }); +}; + +process.stdin.on('close', hadErr => { + server.sendLoggingMessage({ + level: 'debug', + data: 'stdin closed. Error: ' + hadErr + }); +}); +process.on('SIGINT', doNotExitImmediately); +process.on('SIGTERM', doNotExitImmediately); diff --git a/src/integration-tests/test-server.js b/src/integration-tests/test-server.js deleted file mode 100644 index 8c68fc325..000000000 --- a/src/integration-tests/test-server.js +++ /dev/null @@ -1,11 +0,0 @@ -import { McpServer } from '../../dist/esm/server/mcp.js'; -import { StdioServerTransport } from '../../dist/esm/server/stdio.js'; - -const transport = new StdioServerTransport(); - -const server = new McpServer({ - name: 'test-server', - version: '1.0.0' -}); - -await server.connect(transport); diff --git a/src/integration-tests/test-server.ts b/src/integration-tests/test-server.ts new file mode 100644 index 000000000..6401d0f83 --- /dev/null +++ b/src/integration-tests/test-server.ts @@ -0,0 +1,19 @@ +import { McpServer } from '../server/mcp.js'; +import { StdioServerTransport } from '../server/stdio.js'; + +const transport = new StdioServerTransport(); + +const server = new McpServer({ + name: 'test-server', + version: '1.0.0' +}); + +await server.connect(transport); + +const exit = async () => { + await server.close(); + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); From f95739f8ed2d0fb13f12ec97e06abc410523a1c1 Mon Sep 17 00:00:00 2001 From: Johnny Santos Date: Fri, 28 Nov 2025 12:12:10 -0300 Subject: [PATCH 08/13] chore: exclude integration tests from final build --- tsconfig.cjs.json | 2 +- tsconfig.prod.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 3b46f11c4..0870184d9 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -5,5 +5,5 @@ "moduleResolution": "node", "outDir": "./dist/cjs" }, - "exclude": ["**/*.test.ts", "src/__mocks__/**/*"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/integration-tests"] } diff --git a/tsconfig.prod.json b/tsconfig.prod.json index fcf2e951c..6eedfd710 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "./dist/esm" }, - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/server/zodTestMatrix.ts"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/server/zodTestMatrix.ts", "src/integration-tests"] } From 07ae2964871c88acc27d0f70444e980c8daf3c4d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 12:50:25 +0000 Subject: [PATCH 09/13] fix: test stopping vitest exiting --- src/integration-tests/process-cleanup.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts index 1a5b9a35b..b4fbaae77 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/process-cleanup.test.ts @@ -53,8 +53,8 @@ describe('Process cleanup', () => { }); const transport = new StdioClientTransport({ - command: 'npm', - args: ['exec', 'tsx', 'test-server.ts'], + command: 'node', + args: ['--import', 'tsx', 'test-server.ts'], cwd: __dirname }); @@ -80,8 +80,8 @@ describe('Process cleanup', () => { }); const transport = new StdioClientTransport({ - command: 'npm', - args: ['exec', 'tsx', 'server-that-hangs.ts'], + command: 'node', + args: ['--import', 'tsx', 'server-that-hangs.ts'], cwd: __dirname }); From 1eb9340622bd3f929d626eb589a8709de5a1f739 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 12:55:11 +0000 Subject: [PATCH 10/13] remove debug logs --- src/integration-tests/server-that-hangs.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/integration-tests/server-that-hangs.ts b/src/integration-tests/server-that-hangs.ts index 7f82ac3f7..8792448a1 100644 --- a/src/integration-tests/server-that-hangs.ts +++ b/src/integration-tests/server-that-hangs.ts @@ -28,18 +28,5 @@ const doNotExitImmediately = async (signal: NodeJS.Signals) => { setTimeout(() => process.exit(0), 30 * 1000); }; -transport.onclose = () => { - server.sendLoggingMessage({ - level: 'debug', - data: 'transport: onclose called. This should never happen' - }); -}; - -process.stdin.on('close', hadErr => { - server.sendLoggingMessage({ - level: 'debug', - data: 'stdin closed. Error: ' + hadErr - }); -}); process.on('SIGINT', doNotExitImmediately); process.on('SIGTERM', doNotExitImmediately); From 03dd7fa73d2a0d1a5b8454c310a1b97b18eed68b Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 13:18:31 +0000 Subject: [PATCH 11/13] fixtures folder --- .../serverThatHangs.ts} | 0 .../test-server.ts => __fixtures__/testServer.ts} | 0 src/{shared => __fixtures__}/zodTestMatrix.ts | 0 ...process-cleanup.test.ts => processCleanup.test.ts} | 11 +++++++---- .../stateManagementStreamableHttp.test.ts | 2 +- src/integration-tests/taskResumability.test.ts | 2 +- src/server/completable.test.ts | 2 +- src/server/mcp.test.ts | 2 +- src/server/sse.test.ts | 2 +- src/server/streamableHttp.test.ts | 2 +- src/server/title.test.ts | 2 +- tsconfig.cjs.json | 2 +- tsconfig.prod.json | 2 +- 13 files changed, 16 insertions(+), 13 deletions(-) rename src/{integration-tests/server-that-hangs.ts => __fixtures__/serverThatHangs.ts} (100%) rename src/{integration-tests/test-server.ts => __fixtures__/testServer.ts} (100%) rename src/{shared => __fixtures__}/zodTestMatrix.ts (100%) rename src/integration-tests/{process-cleanup.test.ts => processCleanup.test.ts} (92%) diff --git a/src/integration-tests/server-that-hangs.ts b/src/__fixtures__/serverThatHangs.ts similarity index 100% rename from src/integration-tests/server-that-hangs.ts rename to src/__fixtures__/serverThatHangs.ts diff --git a/src/integration-tests/test-server.ts b/src/__fixtures__/testServer.ts similarity index 100% rename from src/integration-tests/test-server.ts rename to src/__fixtures__/testServer.ts diff --git a/src/shared/zodTestMatrix.ts b/src/__fixtures__/zodTestMatrix.ts similarity index 100% rename from src/shared/zodTestMatrix.ts rename to src/__fixtures__/zodTestMatrix.ts diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/processCleanup.test.ts similarity index 92% rename from src/integration-tests/process-cleanup.test.ts rename to src/integration-tests/processCleanup.test.ts index b4fbaae77..7579bebdc 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/processCleanup.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { Readable, Writable } from 'node:stream'; import { Client } from '../client/index.js'; import { StdioClientTransport } from '../client/stdio.js'; @@ -5,6 +6,8 @@ import { Server } from '../server/index.js'; import { StdioServerTransport } from '../server/stdio.js'; import { LoggingMessageNotificationSchema } from '../types.js'; +const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__'); + describe('Process cleanup', () => { vi.setConfig({ testTimeout: 5000 }); // 5 second timeout @@ -54,8 +57,8 @@ describe('Process cleanup', () => { const transport = new StdioClientTransport({ command: 'node', - args: ['--import', 'tsx', 'test-server.ts'], - cwd: __dirname + args: ['--import', 'tsx', 'testServer.ts'], + cwd: FIXTURES_DIR }); await client.connect(transport); @@ -81,8 +84,8 @@ describe('Process cleanup', () => { const transport = new StdioClientTransport({ command: 'node', - args: ['--import', 'tsx', 'server-that-hangs.ts'], - cwd: __dirname + args: ['--import', 'tsx', 'serverThatHangs.ts'], + cwd: FIXTURES_DIR }); await client.connect(transport); diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 3294df4d4..fe79ff9ee 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -12,7 +12,7 @@ import { ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 3c357d171..bf0d4bc46 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -7,7 +7,7 @@ import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js'; import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/src/server/completable.test.ts b/src/server/completable.test.ts index e0d2aba99..69dd67d02 100644 --- a/src/server/completable.test.ts +++ b/src/server/completable.test.ts @@ -1,5 +1,5 @@ import { completable, getCompleter } from './completable.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('completable with $zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index aa9a1477d..376b5c629 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -22,7 +22,7 @@ import { import { completable } from './completable.js'; import { McpServer, ResourceTemplate } from './mcp.js'; import { InMemoryTaskStore } from '../experimental/tasks/stores/in-memory.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; function createLatch() { let latch = false; diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 304f7e860..b95490c13 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -6,7 +6,7 @@ import { McpServer } from './mcp.js'; import { createServer, type Server } from 'node:http'; import { AddressInfo } from 'node:net'; import { CallToolResult, JSONRPCMessage } from '../types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; const createMockResponse = () => { const res = { diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 80ee04d67..39c2e5805 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -5,7 +5,7 @@ import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from './ import { McpServer } from './mcp.js'; import { CallToolResult, JSONRPCMessage } from '../types.js'; import { AuthInfo } from './auth/types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; async function getFreePort() { return new Promise(res => { diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 9eb99b992..2af3de3c0 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -2,7 +2,7 @@ import { Server } from './index.js'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import { McpServer, ResourceTemplate } from './mcp.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 0870184d9..ed5f7fe3e 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -5,5 +5,5 @@ "moduleResolution": "node", "outDir": "./dist/cjs" }, - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/integration-tests"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] } diff --git a/tsconfig.prod.json b/tsconfig.prod.json index 6eedfd710..a07311af7 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "./dist/esm" }, - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/server/zodTestMatrix.ts", "src/integration-tests"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] } From 674e79ff8cf747414309e469a068a572161ab251 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 13:58:23 +0000 Subject: [PATCH 12/13] match mcp spec and python --- src/client/stdio.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/client/stdio.ts b/src/client/stdio.ts index f4a243d35..dcba1915c 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -219,22 +219,21 @@ export class StdioClientTransport implements Transport { }); }); + try { + processToClose.stdin?.end(); + } catch { + // ignore + } + this._abortController.abort(); - // waits the underlying process to exit cleanly otherwise after 1s kills it - await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 1_000).unref())]); + await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]); if (processToClose.exitCode === null) { - try { - processToClose.stdin?.end(); - } catch { - // ignore errors in trying to close stdin - } - try { processToClose.kill('SIGKILL'); } catch { - // we did our best + // ignore } } } From 40ddaa5368e8f39e294095a34b5e16164bf133e0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 14:14:40 +0000 Subject: [PATCH 13/13] align with python and remove unused abost controller. --- src/__fixtures__/serverThatHangs.ts | 14 ++++++++++++-- src/client/stdio.ts | 19 ++++++++++--------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/__fixtures__/serverThatHangs.ts b/src/__fixtures__/serverThatHangs.ts index 8792448a1..82c244aa2 100644 --- a/src/__fixtures__/serverThatHangs.ts +++ b/src/__fixtures__/serverThatHangs.ts @@ -1,4 +1,4 @@ -import { setTimeout } from 'node:timers'; +import { setInterval } from 'node:timers'; import process from 'node:process'; import { McpServer } from '../server/mcp.js'; import { StdioServerTransport } from '../server/stdio.js'; @@ -20,12 +20,22 @@ const server = new McpServer( await server.connect(transport); +// Keep process alive even after stdin closes +const keepAlive = setInterval(() => {}, 60_000); + +// Prevent transport close from exiting +transport.onclose = () => { + // Intentionally ignore - we want to test the signal handling +}; + const doNotExitImmediately = async (signal: NodeJS.Signals) => { await server.sendLoggingMessage({ level: 'debug', data: `received signal ${signal}` }); - setTimeout(() => process.exit(0), 30 * 1000); + // Clear keepalive but delay exit to simulate slow shutdown + clearInterval(keepAlive); + setInterval(() => {}, 30_000); }; process.on('SIGINT', doNotExitImmediately); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index dcba1915c..e488dcd24 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -91,7 +91,6 @@ export function getDefaultEnvironment(): Record { */ export class StdioClientTransport implements Transport { private _process?: ChildProcess; - private _abortController: AbortController = new AbortController(); private _readBuffer: ReadBuffer = new ReadBuffer(); private _serverParams: StdioServerParameters; private _stderrStream: PassThrough | null = null; @@ -126,17 +125,11 @@ export class StdioClientTransport implements Transport { }, stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'], shell: false, - signal: this._abortController.signal, windowsHide: process.platform === 'win32' && isElectron(), cwd: this._serverParams.cwd }); this._process.on('error', error => { - if (error.name === 'AbortError') { - // Expected when close() is called. - return; - } - reject(error); this.onerror?.(error); }); @@ -225,10 +218,18 @@ export class StdioClientTransport implements Transport { // ignore } - this._abortController.abort(); - await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]); + if (processToClose.exitCode === null) { + try { + processToClose.kill('SIGTERM'); + } catch { + // ignore + } + + await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]); + } + if (processToClose.exitCode === null) { try { processToClose.kill('SIGKILL');