Skip to content

Commit 0bcf8fc

Browse files
travisbreaksclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 7ba58da commit 0bcf8fc

File tree

3 files changed

+57
-1
lines changed

3 files changed

+57
-1
lines changed

.changeset/server-host-watchdog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
---
4+
5+
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.

packages/server/src/server/stdio.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ export class StdioServerTransport implements Transport {
3838
_onerror = (error: Error) => {
3939
this.onerror?.(error);
4040
};
41+
_onend = () => void this.close().catch(error => this.onerror?.(error));
4142
_onstdouterror = (error: Error) => {
4243
this.onerror?.(error);
4344
this.close().catch(() => {
44-
// Ignore errors during close we're already in an error path
45+
// Ignore errors during close -- we're already in an error path
4546
});
4647
};
4748

@@ -58,6 +59,7 @@ export class StdioServerTransport implements Transport {
5859
this._started = true;
5960
this._stdin.on('data', this._ondata);
6061
this._stdin.on('error', this._onerror);
62+
this._stdin.on('end', this._onend);
6163
this._stdout.on('error', this._onstdouterror);
6264
}
6365

@@ -85,6 +87,7 @@ export class StdioServerTransport implements Transport {
8587
// Remove our event listeners first
8688
this._stdin.off('data', this._ondata);
8789
this._stdin.off('error', this._onerror);
90+
this._stdin.off('end', this._onend);
8891
this._stdout.off('error', this._onstdouterror);
8992

9093
// Check if we were the only data listener

packages/server/test/server/stdio.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,51 @@ test('should fire onerror before onclose on stdout error', async () => {
179179

180180
expect(events).toEqual(['error', 'close']);
181181
});
182+
183+
describe('stdin EOF', () => {
184+
test('should close transport when stdin emits end', async () => {
185+
const server = new StdioServerTransport(input, output);
186+
187+
const closed = new Promise<void>(resolve => {
188+
server.onclose = () => resolve();
189+
});
190+
191+
await server.start();
192+
193+
// Simulate the host process closing stdin (pipe EOF)
194+
input.push(null);
195+
196+
await closed;
197+
});
198+
199+
test('should not fire onclose twice when close() is called after stdin end', async () => {
200+
const server = new StdioServerTransport(input, output);
201+
202+
let closeCount = 0;
203+
server.onclose = () => {
204+
closeCount++;
205+
};
206+
207+
await server.start();
208+
input.push(null);
209+
210+
// Give the end event time to propagate
211+
await new Promise(resolve => setTimeout(resolve, 50));
212+
213+
await server.close();
214+
expect(closeCount).toBe(1);
215+
});
216+
217+
test('should remove end listener on close', async () => {
218+
const server = new StdioServerTransport(input, output);
219+
await server.start();
220+
221+
const endListenersBefore = input.listenerCount('end');
222+
expect(endListenersBefore).toBeGreaterThan(0);
223+
224+
await server.close();
225+
226+
const endListenersAfter = input.listenerCount('end');
227+
expect(endListenersAfter).toBe(0);
228+
});
229+
});

0 commit comments

Comments
 (0)