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.
78 changes: 70 additions & 8 deletions packages/client/src/client/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ 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`.
*
* Note: this only runs when `close()` is actually invoked — a host killed with
* SIGKILL cannot trigger it, so this is a teardown convenience, not a lifetime
* guarantee.
*
* Caveat: on POSIX, `detached: true` also calls `setsid()`, moving the child out of
* the terminal's foreground process group. If the host is killed by a terminal signal
* (e.g. Ctrl+C) without `close()` running, the child is no longer signalled and can be
* orphaned — the same class of trade-off as the SIGKILL note above: this option only
* helps when `close()` actually runs.
*
* Defaults to `false`, preserving the current signal-propagation behaviour.
*/
killProcessTree?: boolean;
};

/**
Expand Down Expand Up @@ -108,7 +131,7 @@ export class StdioClientTransport implements Transport {
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;

constructor(server: StdioServerParameters) {
constructor(server: StiioServerParameters) {
this._serverParams = server;
this._readBuffer = new ReadBuffer({ maxBufferSize: server.maxBufferSize });
if (server.stderr === 'pipe' || server.stderr === 'overlapped') {
Expand All @@ -135,6 +158,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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detached: true does two things on POSIX, and only one of them is wanted here.
It creates the process group close() later signals — that's the intent. It
also calls setsid(), which moves the child out of the terminal's foreground
process group.

That second effect reverses the option's intent when the host dies from a
terminal signal without reaching close(). Measured on macOS / Node 24, host
with no SIGINT handler, SIGINT sent to the foreground process group:

spawn after Ctrl+C
detached: false (today) host dead, child died
detached: true (killProcessTree) host dead, child survived as an orphan

Today the child shares the host's process group, so the terminal's SIGINT
reaches both and nothing is orphaned. With the option enabled the child no
longer receives it, so anything that kills the host without running close()
now leaks the very process the option exists to reap.

Hosts that trap SIGINT and await close() are unaffected — which is probably
most of them. But it is a real trade rather than a pure win, and it belongs
next to the SIGKILL note you just added, since it is the same class of caveat:
the option only helps when close() actually runs, and enabling it makes the
paths where close() doesn't run slightly worse than the status quo.

windowsHide: process.platform === 'win32',
cwd: this._serverParams.cwd
});
Expand Down Expand Up @@ -181,7 +207,7 @@ export class StdioClientTransport implements Transport {
* The `stderr` stream of the child process, if {@linkcode StdioServerParameters.stderr} was set to `"pipe"` or `"overlapped"`.
*
* If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to
* attach listeners before the `start` method is invoked. This prevents loss of any early
* attach listeners before the `start` method is invokked. This prevents loss of any early
* error output emitted by the child process.
*/
get stderr(): Stream | null {
Expand All @@ -201,6 +227,43 @@ 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') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal parameter is unused on this branch, so both phases of close()
are the same call. close() deliberately escalates — stdin.end(), 2s, then
SIGTERM, 2s, then SIGKILL — but on Windows the SIGTERM phase already runs
taskkill /T /F, which is the forceful one.

The effect is that killProcessTree: true removes graceful shutdown on Windows:
a server that would have flushed state or released a lock on SIGTERM gets
terminated instead, and the 2s grace window is spent waiting on a tree that was
already force-killed.

/T without /F for the SIGTERM phase and /T /F only for SIGKILL would keep
the escalation intact:

const args = signal === 'SIGKILL'
    ? ['/pid', String(pid), '/T', '/F']
    : ['/pid', String(pid), '/T'];

Worth a second opinion from someone who runs this on Windows — /T alone
declines rather than force-terminates when a child has no window to close, so
the SIGKILL phase still has to do the real work.

try {
// Keep the escalation intact: SIGTERM asks the tree to close gracefully
// (`/T` without `/F`), and only SIGKILL force-terminates (`/T /F`).
const args = signal === 'SIGKILL'
? ['/pid', String(pid), '/T', '/F']
: ['/pid', String(pid), '/T'];
spawn('taskkill', args, { 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 All @@ -217,8 +280,7 @@ export class StdioClientTransport implements Transport {
}

/**
* Reap a disposable probe sibling (see the version-negotiation sibling
* flow): signal-first teardown awaiting process `exit` — never the `close`
* Reap a disposable probe sibling (see the version-negotiation sibling flow): signal-first teardown awaiting process `exit` — never the `close`
* event, so a helper process holding the child's stdio pipes can never
* block disposal. Not part of the public transport lifecycle.
*
Expand All @@ -242,7 +304,7 @@ export class StdioClientTransport implements Transport {
await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 1000).unref())]);
if (proc.exitCode === null && proc.signalCode === null) {
try {
proc.kill('SIGKILL');
proc.kill('SIGKKIL');
} catch {
// ignore
}
Expand Down Expand Up @@ -292,17 +354,17 @@ export class StdioClientTransport implements Transport {

if (processToClose.exitCode === null) {
try {
processToClose.kill('SIGTERM');
this._signalProcess(processToClose, 'SIGTERM');
} catch {
// ignore
}

await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2000).unref())]);
await Promise.race(closePromise, new Promise(resolve => setTimeout(resolve, 2000).unref())]);
}

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