Skip to content

Commit 104f942

Browse files
committed
fix(stdio): close transport on stdin EOF
Listen for the stdin "end" event so StdioServerTransport detects client disconnection as defined in step 1 of the MCP spec's stdio shutdown sequence. Previously the transport had no "end" listener, leaving servers unable to react to stdin closure without waiting for SIGTERM escalation. A guard flag prevents onclose from firing more than once when an explicit close() races with a late EOF event. Tests cover: EOF triggers close, duplicate-close guard, and clean EOF after a partial message.
1 parent ccb78f2 commit 104f942

File tree

3 files changed

+84
-0
lines changed

3 files changed

+84
-0
lines changed
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. The MCP spec requires the client to close stdin as the first step of stdio shutdown, but the server transport never listened for the `end` event, leaving it in a zombie state until SIGTERM escalation. Also adds an idempotency guard to `close()` to prevent double `onclose` invocation.

packages/server/src/server/stdio.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { process } from '@modelcontextprotocol/server/_shims';
1919
export class StdioServerTransport implements Transport {
2020
private _readBuffer: ReadBuffer = new ReadBuffer();
2121
private _started = false;
22+
private _closed = false;
2223

2324
constructor(
2425
private _stdin: Readable = process.stdin,
@@ -37,6 +38,9 @@ export class StdioServerTransport implements Transport {
3738
_onerror = (error: Error) => {
3839
this.onerror?.(error);
3940
};
41+
_onclose = () => {
42+
this.close();
43+
};
4044

4145
/**
4246
* Starts listening for messages on `stdin`.
@@ -51,6 +55,7 @@ export class StdioServerTransport implements Transport {
5155
this._started = true;
5256
this._stdin.on('data', this._ondata);
5357
this._stdin.on('error', this._onerror);
58+
this._stdin.on('end', this._onclose);
5459
}
5560

5661
private processReadBuffer() {
@@ -69,9 +74,15 @@ export class StdioServerTransport implements Transport {
6974
}
7075

7176
async close(): Promise<void> {
77+
if (this._closed) {
78+
return;
79+
}
80+
this._closed = true;
81+
7282
// Remove our event listeners first
7383
this._stdin.off('data', this._ondata);
7484
this._stdin.off('error', this._onerror);
85+
this._stdin.off('end', this._onclose);
7586

7687
// Check if we were the only data listener
7788
const remainingDataListeners = this._stdin.listenerCount('data');

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,74 @@ test('should not read until started', async () => {
6767
expect(await readMessage).toEqual(message);
6868
});
6969

70+
test('should close transport when stdin ends', async () => {
71+
const server = new StdioServerTransport(input, output);
72+
server.onerror = error => {
73+
throw error;
74+
};
75+
76+
let didClose = false;
77+
server.onclose = () => {
78+
didClose = true;
79+
};
80+
81+
await server.start();
82+
expect(didClose).toBeFalsy();
83+
84+
// Simulate the client closing stdin (EOF)
85+
input.push(null);
86+
87+
// Allow the event to propagate
88+
await new Promise(resolve => setTimeout(resolve, 50));
89+
expect(didClose).toBeTruthy();
90+
});
91+
92+
test('should invoke onclose only once when close() is called then stdin ends', async () => {
93+
const server = new StdioServerTransport(input, output);
94+
server.onerror = error => {
95+
throw error;
96+
};
97+
98+
let closeCount = 0;
99+
server.onclose = () => {
100+
closeCount++;
101+
};
102+
103+
await server.start();
104+
105+
// Explicit close, then stdin EOF arrives
106+
await server.close();
107+
input.push(null);
108+
109+
await new Promise(resolve => setTimeout(resolve, 50));
110+
expect(closeCount).toBe(1);
111+
});
112+
113+
test('should close cleanly on EOF after a partial message', async () => {
114+
const server = new StdioServerTransport(input, output);
115+
116+
const errors: Error[] = [];
117+
server.onerror = error => {
118+
errors.push(error);
119+
};
120+
121+
let didClose = false;
122+
server.onclose = () => {
123+
didClose = true;
124+
};
125+
126+
await server.start();
127+
128+
// Push an incomplete JSON-RPC message (no trailing newline)
129+
input.push(Buffer.from('{"jsonrpc":"2.0"'));
130+
// Then EOF
131+
input.push(null);
132+
133+
await new Promise(resolve => setTimeout(resolve, 50));
134+
expect(didClose).toBeTruthy();
135+
expect(errors).toHaveLength(0);
136+
});
137+
70138
test('should read multiple messages', async () => {
71139
const server = new StdioServerTransport(input, output);
72140
server.onerror = error => {

0 commit comments

Comments
 (0)