Skip to content

Commit 6abe472

Browse files
travisbreaksTadao
andcommitted
feat(server): close StdioServerTransport on stdin EOF
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. Replaces the previous watchdog approach with the simpler stdin EOF pattern used by the Python and Kotlin SDKs. Co-Authored-By: Tadao <tadao@travisfixes.com>
1 parent 0021561 commit 6abe472

File tree

3 files changed

+59
-1
lines changed

3 files changed

+59
-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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,13 @@ export class StdioServerTransport implements Transport {
3838
_onerror = (error: Error) => {
3939
this.onerror?.(error);
4040
};
41+
_onend = () => {
42+
void this.close().catch(error => this.onerror?.(error));
43+
};
4144
_onstdouterror = (error: Error) => {
4245
this.onerror?.(error);
4346
this.close().catch(() => {
44-
// Ignore errors during close we're already in an error path
47+
// Ignore errors during close -- we're already in an error path
4548
});
4649
};
4750

@@ -58,6 +61,7 @@ export class StdioServerTransport implements Transport {
5861
this._started = true;
5962
this._stdin.on('data', this._ondata);
6063
this._stdin.on('error', this._onerror);
64+
this._stdin.on('end', this._onend);
6165
this._stdout.on('error', this._onstdouterror);
6266
}
6367

@@ -85,6 +89,7 @@ export class StdioServerTransport implements Transport {
8589
// Remove our event listeners first
8690
this._stdin.off('data', this._ondata);
8791
this._stdin.off('error', this._onerror);
92+
this._stdin.off('end', this._onend);
8893
this._stdout.off('error', this._onstdouterror);
8994

9095
// 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)