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
5 changes: 5 additions & 0 deletions .changeset/stdio-kill-process-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': minor
---

Add an opt-in `killProcessTree` option to `StdioClientTransport`. When enabled, `close()` tears down the entire process tree — the child is spawned as its own process-group leader on POSIX (signalled via the process group) and torn down with `taskkill /T /F` on Windows — preventing orphaned server processes when the server is launched through a wrapper such as `npx`, `uvx`, or `python -m`. Defaults to `false`, preserving existing signal-propagation behaviour. Fixes #2023.
52 changes: 50 additions & 2 deletions packages/client/src/client/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ export type StdioServerParameters = {
* Defaults to 10 MB.
*/
maxBufferSize?: number;

/**
* Kill the entire process tree when {@linkcode StdioClientTransport.close} is called.
*
* MCP servers are commonly launched through a wrapper (`npx`, `uvx`, `python -m`).
* `ChildProcess.kill()` signals only the direct child, so the wrapper's children survive
* as orphans. When this is `true`, the child is spawned as its own process-group leader
* (POSIX) and `close()` signals the whole group; on Windows the tree is torn down with
* `taskkill /T /F`.
*
* Defaults to `false`, preserving the current signal-propagation behaviour.
*/
killProcessTree?: boolean;
};

/**
Expand Down Expand Up @@ -135,6 +148,9 @@ export class StdioClientTransport implements Transport {
},
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false,
// Own process group, so close() can signal the whole tree. Windows has no
// process groups in this sense; `taskkill /T` covers it there.
detached: this._serverParams.killProcessTree === true && process.platform !== 'win32',
windowsHide: process.platform === 'win32',
cwd: this._serverParams.cwd
});
Expand Down Expand Up @@ -201,6 +217,38 @@ export class StdioClientTransport implements Transport {
return this._process?.pid ?? null;
}

/**
* Signal the child, or its whole tree when `killProcessTree` is set.
* Always falls back to the plain single-process kill.
*/
private _signalProcess(proc: ChildProcess, signal: 'SIGTERM' | 'SIGKILL'): void {
const pid = proc.pid;

if (!this._serverParams.killProcessTree || pid === undefined) {
proc.kill(signal);
return;
}

if (process.platform === 'win32') {
try {
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
return;
} catch {
// fall through to the direct kill below
}
} else {
try {
// Negative pid addresses the group created by `detached: true`.
process.kill(-pid, signal);
return;
} catch {
// Group already gone, or we never became the leader.
}
}

proc.kill(signal);
}

private processReadBuffer() {
while (true) {
try {
Expand Down Expand Up @@ -292,7 +340,7 @@ export class StdioClientTransport implements Transport {

if (processToClose.exitCode === null) {
try {
processToClose.kill('SIGTERM');
this._signalProcess(processToClose, 'SIGTERM');
} catch {
// ignore
}
Expand All @@ -302,7 +350,7 @@ export class StdioClientTransport implements Transport {

if (processToClose.exitCode === null) {
try {
processToClose.kill('SIGKILL');
this._signalProcess(processToClose, 'SIGKILL');
} catch {
// ignore
}
Expand Down
42 changes: 42 additions & 0 deletions packages/client/test/client/stdioKillProcessTree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';

import { StdioClientTransport } from '../../src/client/stdio';

test('killProcessTree terminates grandchildren spawned by a wrapper', async () => {
// The npx/uvx anatomy: the direct child is a wrapper that spawns the real server.
// Without process-group teardown the grandchild outlives close() as an orphan.
if (process.platform === 'win32') return; // taskkill path is covered manually

const pidFile = `${tmpdir()}/mcp-tree-${process.pid}-${Date.now()}`;
const WRAPPER_SCRIPT = String.raw`
const { spawn } = require('child_process');
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));
setInterval(() => {}, 1000);
`;

const transport = new StdioClientTransport({
command: process.execPath,
args: ['-e', WRAPPER_SCRIPT],
killProcessTree: true
});
await transport.start();

while (!existsSync(pidFile)) await new Promise(resolve => setTimeout(resolve, 25));
const grandchildPid = Number(readFileSync(pidFile, 'utf8'));
expect(() => process.kill(grandchildPid, 0)).not.toThrow();

await transport.close();

// The group signal is delivered asynchronously; give it a moment to land.
for (let i = 0; i < 40; i++) {
try {
process.kill(grandchildPid, 0);
} catch {
return; // gone — the tree was reaped
}
await new Promise(resolve => setTimeout(resolve, 25));
}
throw new Error(`grandchild ${grandchildPid} survived close()`);
}, 15_000);
Loading