From 9378f8ba8d13077e1b96a4d6001fb813a82a3433 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 30 Apr 2026 21:16:13 -0400 Subject: [PATCH 1/4] fix(server): exit when MCP client closes stdin pipe Co-Authored-By: Claude Sonnet 4.6 --- packages/server/src/server/stdio.ts | 7 ++++ packages/server/test/server/stdio.test.ts | 43 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index ac2dd3f78..232766ac9 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -44,6 +44,11 @@ export class StdioServerTransport implements Transport { // Ignore errors during close — we're already in an error path }); }; + _onstdinclose = () => { + this.close().catch(() => { + // Ignore errors during close — stdin pipe ended + }); + }; /** * Starts listening for messages on `stdin`. @@ -58,6 +63,7 @@ export class StdioServerTransport implements Transport { this._started = true; this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); + this._stdin.on('close', this._onstdinclose); this._stdout.on('error', this._onstdouterror); } @@ -85,6 +91,7 @@ export class StdioServerTransport implements Transport { // Remove our event listeners first this._stdin.off('data', this._ondata); this._stdin.off('error', this._onerror); + this._stdin.off('close', this._onstdinclose); this._stdout.off('error', this._onstdouterror); // Check if we were the only data listener diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 92671cacd..c46d9e266 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -179,3 +179,46 @@ test('should fire onerror before onclose on stdout error', async () => { expect(events).toEqual(['error', 'close']); }); + +test('should fire onclose when stdin emits close', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { throw error; }; + + let closeCount = 0; + server.onclose = () => { closeCount++; }; + + await server.start(); + input.emit('close'); + + expect(closeCount).toBe(1); +}); + +test('should fire onclose when stdin emits end', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { throw error; }; + + let closeCount = 0; + server.onclose = () => { closeCount++; }; + + await server.start(); + input.push(null); // signals end-of-stream + + // Allow microtasks to flush + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(closeCount).toBe(1); +}); + +test('should not fire onclose twice when close() called after stdin close', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = () => {}; + + let closeCount = 0; + server.onclose = () => { closeCount++; }; + + await server.start(); + input.emit('close'); + await server.close(); + + expect(closeCount).toBe(1); +}); From a3a4e0492c51bb68c4a2fa523feca2b5c67032bc Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 30 Apr 2026 21:20:47 -0400 Subject: [PATCH 2/4] fix(server): also handle stdin end event, fix end test isolation --- packages/server/src/server/stdio.ts | 7 +++++++ packages/server/test/server/stdio.test.ts | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 232766ac9..21be51087 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -49,6 +49,11 @@ export class StdioServerTransport implements Transport { // Ignore errors during close — stdin pipe ended }); }; + _onstdinend = () => { + this.close().catch(() => { + // Ignore errors during close — stdin pipe ended + }); + }; /** * Starts listening for messages on `stdin`. @@ -64,6 +69,7 @@ export class StdioServerTransport implements Transport { this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); this._stdin.on('close', this._onstdinclose); + this._stdin.on('end', this._onstdinend); this._stdout.on('error', this._onstdouterror); } @@ -92,6 +98,7 @@ export class StdioServerTransport implements Transport { this._stdin.off('data', this._ondata); this._stdin.off('error', this._onerror); this._stdin.off('close', this._onstdinclose); + this._stdin.off('end', this._onstdinend); this._stdout.off('error', this._onstdouterror); // Check if we were the only data listener diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index c46d9e266..83bb54593 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -194,18 +194,28 @@ test('should fire onclose when stdin emits close', async () => { }); test('should fire onclose when stdin emits end', async () => { - const server = new StdioServerTransport(input, output); + // Use autoDestroy:false + emitClose:false so push(null) fires 'end' but NOT 'close', + // ensuring the test fails unless an 'end' listener is explicitly registered. + const endOnlyInput = new Readable({ + autoDestroy: false, + emitClose: false, + read: () => {} + }); + const server = new StdioServerTransport(endOnlyInput, output); server.onerror = error => { throw error; }; let closeCount = 0; + let inputCloseCount = 0; server.onclose = () => { closeCount++; }; + endOnlyInput.on('close', () => { inputCloseCount++; }); await server.start(); - input.push(null); // signals end-of-stream + endOnlyInput.push(null); // signals end-of-stream without emitting close // Allow microtasks to flush await new Promise(resolve => setTimeout(resolve, 0)); + expect(inputCloseCount).toBe(0); // confirms close was NOT emitted expect(closeCount).toBe(1); }); From 7e70ca3a4c04723b3a0d1541bcf2fb947d763aab Mon Sep 17 00:00:00 2001 From: Elliot Drel <156480527+ElliotDrel@users.noreply.github.com> Date: Fri, 1 May 2026 13:20:51 -0400 Subject: [PATCH 3/4] chore: add changeset for stdin close fix --- .changeset/fix-stdio-stdin-close-exit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-stdio-stdin-close-exit.md diff --git a/.changeset/fix-stdio-stdin-close-exit.md b/.changeset/fix-stdio-stdin-close-exit.md new file mode 100644 index 000000000..15c1f1853 --- /dev/null +++ b/.changeset/fix-stdio-stdin-close-exit.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server": patch +--- + +Exit server process when MCP client closes stdin pipe. Previously, `StdioServerTransport` failed to detect when a client closed its stdin pipe, causing server processes to accumulate as zombies. This adds `close` and `end` event listeners on stdin that trigger proper transport cleanup. From 27f08740e9d7588bd761096983786a21b40db931 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 1 May 2026 21:23:54 -0400 Subject: [PATCH 4/4] fix(lint): apply prettier formatting to stdio server test file --- packages/server/test/server/stdio.test.ts | 24 +++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 83bb54593..9025ea3ce 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -182,10 +182,14 @@ test('should fire onerror before onclose on stdout error', async () => { test('should fire onclose when stdin emits close', async () => { const server = new StdioServerTransport(input, output); - server.onerror = error => { throw error; }; + server.onerror = error => { + throw error; + }; let closeCount = 0; - server.onclose = () => { closeCount++; }; + server.onclose = () => { + closeCount++; + }; await server.start(); input.emit('close'); @@ -202,12 +206,18 @@ test('should fire onclose when stdin emits end', async () => { read: () => {} }); const server = new StdioServerTransport(endOnlyInput, output); - server.onerror = error => { throw error; }; + server.onerror = error => { + throw error; + }; let closeCount = 0; let inputCloseCount = 0; - server.onclose = () => { closeCount++; }; - endOnlyInput.on('close', () => { inputCloseCount++; }); + server.onclose = () => { + closeCount++; + }; + endOnlyInput.on('close', () => { + inputCloseCount++; + }); await server.start(); endOnlyInput.push(null); // signals end-of-stream without emitting close @@ -224,7 +234,9 @@ test('should not fire onclose twice when close() called after stdin close', asyn server.onerror = () => {}; let closeCount = 0; - server.onclose = () => { closeCount++; }; + server.onclose = () => { + closeCount++; + }; await server.start(); input.emit('close');