Skip to content

Commit 7d89648

Browse files
travisbreakswindro-xddclaude
committed
feat(server): add host process watchdog to StdioServerTransport
When clientProcessId is provided, the transport periodically checks if the host process is still alive using signal 0 and self-terminates if it is gone. This prevents orphaned server processes when the host crashes or is killed without cleanly shutting down the server. Follows the pattern used by vscode-languageserver-node as described in the issue. Addresses review feedback: - Rebased onto main (integrates _closed guard, _onstdouterror, new send()) - Treat EPERM as process-still-alive in watchdog catch block - Add kill() stub to shimsWorkerd.ts for workerd compatibility - Export StdioServerTransportOptions from packages/server/src/index.ts Fixes #208 Co-Authored-By: windro-xdd <windro-xdd@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0021561 commit 7d89648

File tree

5 files changed

+180
-6
lines changed

5 files changed

+180
-6
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': minor
3+
---
4+
5+
Add host process watchdog to StdioServerTransport. When `clientProcessId` is provided via the new options object constructor, the transport periodically checks if the host process is still alive and self-terminates if it is gone, preventing orphaned server processes.

packages/server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type { HostHeaderValidationResult } from './server/middleware/hostHeaderV
2828
export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js';
2929
export type { ServerOptions } from './server/server.js';
3030
export { Server } from './server/server.js';
31+
export type { StdioServerTransportOptions } from './server/stdio.js';
3132
export { StdioServerTransport } from './server/stdio.js';
3233
export type {
3334
EventId,

packages/server/src/server/stdio.ts

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,37 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
44
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';
55
import { process } from '@modelcontextprotocol/server/_shims';
66

7+
/**
8+
* Options for configuring `StdioServerTransport`.
9+
*/
10+
export interface StdioServerTransportOptions {
11+
/**
12+
* The readable stream to use for input. Defaults to `process.stdin`.
13+
*/
14+
stdin?: Readable;
15+
16+
/**
17+
* The writable stream to use for output. Defaults to `process.stdout`.
18+
*/
19+
stdout?: Writable;
20+
21+
/**
22+
* The PID of the client (host) process. When set, the transport periodically
23+
* checks if the host process is still alive and self-terminates if it is gone.
24+
*
25+
* This prevents orphaned server processes when the host crashes or is killed
26+
* without cleanly shutting down the server. Follows the same pattern used by
27+
* the Language Server Protocol in vscode-languageserver-node.
28+
*/
29+
clientProcessId?: number;
30+
31+
/**
32+
* How often (in milliseconds) to check if the host process is alive.
33+
* Only used when `clientProcessId` is set. Defaults to 3000 (3 seconds).
34+
*/
35+
watchdogIntervalMs?: number;
36+
}
37+
738
/**
839
* Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`.
940
*
@@ -20,11 +51,29 @@ export class StdioServerTransport implements Transport {
2051
private _readBuffer: ReadBuffer = new ReadBuffer();
2152
private _started = false;
2253
private _closed = false;
23-
24-
constructor(
25-
private _stdin: Readable = process.stdin,
26-
private _stdout: Writable = process.stdout
27-
) {}
54+
private _clientProcessId?: number;
55+
private _watchdogInterval?: ReturnType<typeof setInterval>;
56+
private _watchdogIntervalMs: number;
57+
private _stdin: Readable;
58+
private _stdout: Writable;
59+
60+
constructor(options?: StdioServerTransportOptions);
61+
constructor(stdin?: Readable, stdout?: Writable);
62+
constructor(stdinOrOptions?: Readable | StdioServerTransportOptions, stdout?: Writable) {
63+
if (stdinOrOptions && typeof stdinOrOptions === 'object' && !('read' in stdinOrOptions)) {
64+
// Options object form
65+
const options = stdinOrOptions as StdioServerTransportOptions;
66+
this._stdin = options.stdin ?? process.stdin;
67+
this._stdout = options.stdout ?? process.stdout;
68+
this._clientProcessId = options.clientProcessId;
69+
this._watchdogIntervalMs = options.watchdogIntervalMs ?? 3000;
70+
} else {
71+
// Legacy positional args form
72+
this._stdin = (stdinOrOptions as Readable) ?? process.stdin;
73+
this._stdout = stdout ?? process.stdout;
74+
this._watchdogIntervalMs = 3000;
75+
}
76+
}
2877

2978
onclose?: () => void;
3079
onerror?: (error: Error) => void;
@@ -41,7 +90,7 @@ export class StdioServerTransport implements Transport {
4190
_onstdouterror = (error: Error) => {
4291
this.onerror?.(error);
4392
this.close().catch(() => {
44-
// Ignore errors during close we're already in an error path
93+
// Ignore errors during close -- we're already in an error path
4594
});
4695
};
4796

@@ -59,6 +108,43 @@ export class StdioServerTransport implements Transport {
59108
this._stdin.on('data', this._ondata);
60109
this._stdin.on('error', this._onerror);
61110
this._stdout.on('error', this._onstdouterror);
111+
this._startHostWatchdog();
112+
}
113+
114+
private _startHostWatchdog(): void {
115+
if (this._clientProcessId === undefined || this._watchdogInterval) {
116+
return;
117+
}
118+
119+
const pid = this._clientProcessId;
120+
this._watchdogInterval = setInterval(() => {
121+
try {
122+
// Signal 0 does not kill the process; it checks if it exists.
123+
process.kill(pid, 0);
124+
} catch (error: unknown) {
125+
// ESRCH means the process does not exist: host is gone.
126+
// EPERM means permission denied but the process is still alive
127+
// (some platforms return EPERM instead of ESRCH for signal 0).
128+
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
129+
return;
130+
}
131+
// Host process is gone. Self-terminate.
132+
this._stopHostWatchdog();
133+
void this.close();
134+
}
135+
}, this._watchdogIntervalMs);
136+
137+
// Ensure the watchdog timer does not prevent the process from exiting.
138+
if (typeof this._watchdogInterval === 'object' && 'unref' in this._watchdogInterval) {
139+
this._watchdogInterval.unref();
140+
}
141+
}
142+
143+
private _stopHostWatchdog(): void {
144+
if (this._watchdogInterval) {
145+
clearInterval(this._watchdogInterval);
146+
this._watchdogInterval = undefined;
147+
}
62148
}
63149

64150
private processReadBuffer() {
@@ -81,6 +167,7 @@ export class StdioServerTransport implements Transport {
81167
return;
82168
}
83169
this._closed = true;
170+
this._stopHostWatchdog();
84171

85172
// Remove our event listeners first
86173
this._stdin.off('data', this._ondata);

packages/server/src/shimsWorkerd.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,8 @@ export const process = {
1919
},
2020
get stdout(): never {
2121
return notSupported();
22+
},
23+
kill(): never {
24+
return notSupported();
2225
}
2326
};

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import process from 'node:process';
12
import { Readable, Writable } from 'node:stream';
23

34
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
45
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';
56

7+
import type { StdioServerTransportOptions } from '../../src/server/stdio.js';
68
import { StdioServerTransport } from '../../src/server/stdio.js';
79

810
let input: Readable;
@@ -179,3 +181,79 @@ test('should fire onerror before onclose on stdout error', async () => {
179181

180182
expect(events).toEqual(['error', 'close']);
181183
});
184+
185+
test('should accept options object constructor', async () => {
186+
const server = new StdioServerTransport({ stdin: input, stdout: output });
187+
server.onerror = error => {
188+
throw error;
189+
};
190+
191+
let didClose = false;
192+
server.onclose = () => {
193+
didClose = true;
194+
};
195+
196+
await server.start();
197+
await server.close();
198+
expect(didClose).toBeTruthy();
199+
});
200+
201+
describe('host process watchdog', () => {
202+
test('should close transport when host process is gone', async () => {
203+
// Use a PID that does not exist
204+
const deadPid = 2147483647;
205+
const server = new StdioServerTransport({
206+
stdin: input,
207+
stdout: output,
208+
clientProcessId: deadPid,
209+
watchdogIntervalMs: 100
210+
});
211+
212+
const closed = new Promise<void>(resolve => {
213+
server.onclose = () => resolve();
214+
});
215+
216+
await server.start();
217+
218+
// Watchdog should detect the dead PID and close
219+
await closed;
220+
}, 10000);
221+
222+
test('should not close when host process is alive', async () => {
223+
// Use our own PID, which is always alive
224+
const server = new StdioServerTransport({
225+
stdin: input,
226+
stdout: output,
227+
clientProcessId: process.pid,
228+
watchdogIntervalMs: 100
229+
});
230+
231+
let didClose = false;
232+
server.onclose = () => {
233+
didClose = true;
234+
};
235+
236+
await server.start();
237+
238+
// Wait for several watchdog cycles
239+
await new Promise(resolve => setTimeout(resolve, 350));
240+
expect(didClose).toBe(false);
241+
242+
await server.close();
243+
});
244+
245+
test('should stop watchdog on close', async () => {
246+
const server = new StdioServerTransport({
247+
stdin: input,
248+
stdout: output,
249+
clientProcessId: process.pid,
250+
watchdogIntervalMs: 100
251+
});
252+
253+
await server.start();
254+
await server.close();
255+
256+
// If watchdog was not stopped, it would keep running. Verify no errors after close.
257+
await new Promise(resolve => setTimeout(resolve, 300));
258+
});
259+
});

0 commit comments

Comments
 (0)