From 0bcf8fcfe72c53c332fef520309de7dee11a8c2b Mon Sep 17 00:00:00 2001 From: Travis Bonnet Date: Fri, 10 Apr 2026 00:54:49 -0500 Subject: [PATCH] refactor: simplify to stdin EOF handling per review feedback Listen for the stdin `end` event to detect when the host process exits or is killed, and close the transport accordingly. This prevents orphaned server processes when the host terminates without cleanly shutting down the server. Implements the simpler stdin EOF pattern used by the Python and Kotlin SDKs, as suggested in review. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/server-host-watchdog.md | 5 +++ packages/server/src/server/stdio.ts | 5 ++- packages/server/test/server/stdio.test.ts | 48 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 .changeset/server-host-watchdog.md diff --git a/.changeset/server-host-watchdog.md b/.changeset/server-host-watchdog.md new file mode 100644 index 000000000..a0b8c66c7 --- /dev/null +++ b/.changeset/server-host-watchdog.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Close StdioServerTransport when stdin reaches EOF. When the host process exits or is killed, the stdin pipe closes and the transport now detects this via the `end` event, preventing orphaned server processes. Aligns with the behavior of the Python and Kotlin SDKs. diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index ac2dd3f78..127eb6010 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -38,10 +38,11 @@ export class StdioServerTransport implements Transport { _onerror = (error: Error) => { this.onerror?.(error); }; + _onend = () => void this.close().catch(error => this.onerror?.(error)); _onstdouterror = (error: Error) => { this.onerror?.(error); this.close().catch(() => { - // Ignore errors during close — we're already in an error path + // Ignore errors during close -- we're already in an error path }); }; @@ -58,6 +59,7 @@ export class StdioServerTransport implements Transport { this._started = true; this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); + this._stdin.on('end', this._onend); this._stdout.on('error', this._onstdouterror); } @@ -85,6 +87,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('end', this._onend); 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..3d044874d 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -179,3 +179,51 @@ test('should fire onerror before onclose on stdout error', async () => { expect(events).toEqual(['error', 'close']); }); + +describe('stdin EOF', () => { + test('should close transport when stdin emits end', async () => { + const server = new StdioServerTransport(input, output); + + const closed = new Promise(resolve => { + server.onclose = () => resolve(); + }); + + await server.start(); + + // Simulate the host process closing stdin (pipe EOF) + input.push(null); + + await closed; + }); + + test('should not fire onclose twice when close() is called after stdin end', async () => { + const server = new StdioServerTransport(input, output); + + let closeCount = 0; + server.onclose = () => { + closeCount++; + }; + + await server.start(); + input.push(null); + + // Give the end event time to propagate + await new Promise(resolve => setTimeout(resolve, 50)); + + await server.close(); + expect(closeCount).toBe(1); + }); + + test('should remove end listener on close', async () => { + const server = new StdioServerTransport(input, output); + await server.start(); + + const endListenersBefore = input.listenerCount('end'); + expect(endListenersBefore).toBeGreaterThan(0); + + await server.close(); + + const endListenersAfter = input.listenerCount('end'); + expect(endListenersAfter).toBe(0); + }); +});