Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type { HostHeaderValidationResult } from './server/middleware/hostHeaderV
export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js';
export type { ServerOptions } from './server/server.js';
export { Server } from './server/server.js';
export type { StdioServerTransportOptions } from './server/stdio.js';
export { StdioServerTransport } from './server/stdio.js';
export type {
EventId,
Expand Down
14 changes: 14 additions & 0 deletions packages/server/src/server/stdio.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,17 @@ async function StdioServerTransport_basicUsage() {
await server.connect(transport);
//#endregion StdioServerTransport_basicUsage
}

/**
* Example: Stdio transport with parent process monitoring.
*/
async function StdioServerTransport_parentProcessMonitoring() {
//#region StdioServerTransport_parentProcessMonitoring
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new StdioServerTransport(process.stdin, process.stdout, {
parentPid: process.ppid,
parentCheckInterval: 3000
});
await server.connect(transport);
//#endregion StdioServerTransport_parentProcessMonitoring
}
69 changes: 67 additions & 2 deletions packages/server/src/server/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';
import { process } from '@modelcontextprotocol/server/_shims';

/**
* Options for StdioServerTransport
*/
export interface StdioServerTransportOptions {
/**
* Optional parent process ID to monitor. If provided, the transport will periodically check
* if the parent process is still alive and close itself when the parent exits.
* This helps prevent zombie processes.
*
* @default undefined (no monitoring)
*/
parentPid?: number;

/**
* Interval in milliseconds for checking parent process liveness
*
* @default 3000
*/
parentCheckInterval?: number;
}

/**
* Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`.
*
Expand All @@ -20,11 +41,18 @@ export class StdioServerTransport implements Transport {
private _readBuffer: ReadBuffer = new ReadBuffer();
private _started = false;
private _closed = false;
private _parentCheckTimer?: NodeJS.Timeout;
private _parentPid?: number;
private _parentCheckInterval: number;

constructor(
private _stdin: Readable = process.stdin,
private _stdout: Writable = process.stdout
) {}
private _stdout: Writable = process.stdout,
options?: StdioServerTransportOptions
) {
this._parentPid = options?.parentPid;
this._parentCheckInterval = options?.parentCheckInterval ?? 3000;
}

onclose?: () => void;
onerror?: (error: Error) => void;
Expand Down Expand Up @@ -59,6 +87,38 @@ export class StdioServerTransport implements Transport {
this._stdin.on('data', this._ondata);
this._stdin.on('error', this._onerror);
this._stdout.on('error', this._onstdouterror);

// Start parent process monitoring if parentPid was provided
if (this._parentPid !== undefined) {
this._startParentMonitoring();
}
}

/**
* Starts periodic checks to see if the parent process is still alive.
* If the parent process has exited, this transport will close itself.
*/
private _startParentMonitoring(): void {
this._parentCheckTimer = setInterval(() => {
try {
// process.kill with signal 0 checks if process exists without actually killing it
process.kill(this._parentPid!, 0);
} catch (error) {
const errno = error as NodeJS.ErrnoException;
if (errno.code === 'EPERM') {
// Process exists but we don't have permission to signal it.
return;
}

// Parent process no longer exists - close this transport
this.close().catch(() => {
// Ignore errors during close
});
}
}, this._parentCheckInterval);

// Prevent the timer from keeping the process alive
this._parentCheckTimer.unref();
}

private processReadBuffer() {
Expand All @@ -82,6 +142,11 @@ export class StdioServerTransport implements Transport {
}
this._closed = true;

if (this._parentCheckTimer !== undefined) {
clearInterval(this._parentCheckTimer);
this._parentCheckTimer = undefined;
}

// Remove our event listeners first
this._stdin.off('data', this._ondata);
this._stdin.off('error', this._onerror);
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/shimsWorkerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ export const process = {
},
get stdout(): never {
return notSupported();
},
kill(): never {
return notSupported();
}
};
53 changes: 53 additions & 0 deletions packages/server/test/server/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Readable, Writable } from 'node:stream';

import type { JSONRPCMessage } from '@modelcontextprotocol/core';
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';
import { vi } from 'vitest';

import { StdioServerTransport } from '../../src/server/stdio.js';

Expand Down Expand Up @@ -179,3 +180,55 @@ test('should fire onerror before onclose on stdout error', async () => {

expect(events).toEqual(['error', 'close']);
});

test('should close transport when monitored parent process exits', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation((_pid, _signal) => {
const err = new Error('No such process') as NodeJS.ErrnoException;
err.code = 'ESRCH';
throw err;
});

try {
const server = new StdioServerTransport(input, output, {
parentPid: 424242,
parentCheckInterval: 1
});

let didClose = false;
server.onclose = () => {
didClose = true;
};

await server.start();

// Wait briefly for parent monitor tick
await new Promise(resolve => setTimeout(resolve, 10));

expect(killSpy).toHaveBeenCalledWith(424242, 0);
expect(didClose).toBeTruthy();
} finally {
killSpy.mockRestore();
}
});

test('should clear parent monitor timer on close', async () => {
const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true);

try {
const server = new StdioServerTransport(input, output, {
parentPid: 99999,
parentCheckInterval: 5
});

await server.start();
await server.close();

const callsAtClose = killSpy.mock.calls.length;
await new Promise(resolve => setTimeout(resolve, 20));

// No additional kill checks should happen after close
expect(killSpy.mock.calls.length).toBe(callsAtClose);
} finally {
killSpy.mockRestore();
}
});
Loading