Skip to content

Implement MCP stdio shutdown spec compliance with graceful shutdown manager #250207

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 13, 2025
Merged
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
51 changes: 46 additions & 5 deletions src/vs/base/node/processes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ import { Stats, promises } from 'fs';
import { getCaseInsensitive } from '../common/objects.js';
import * as path from '../common/path.js';
import * as Platform from '../common/platform.js';
import * as process from '../common/process.js';
import * as processCommon from '../common/process.js';
import { CommandOptions, ForkOptions, Source, SuccessData, TerminateResponse, TerminateResponseCode } from '../common/processes.js';
import * as Types from '../common/types.js';
import * as pfs from './pfs.js';
import { FileAccess } from '../common/network.js';
import Stream from 'stream';
export { Source, TerminateResponseCode, type CommandOptions, type ForkOptions, type SuccessData, type TerminateResponse };

export type ValueCallback<T> = (value: T | Promise<T>) => void;
export type ErrorCallback = (error?: any) => void;
export type ProgressCallback<T> = (progress: T) => void;


export function getWindowsShell(env = process.env as Platform.IProcessEnvironment): string {
export function getWindowsShell(env = processCommon.env as Platform.IProcessEnvironment): string {
return env['comspec'] || 'cmd.exe';
}

Expand Down Expand Up @@ -81,17 +83,17 @@ async function fileExistsDefault(path: string): Promise<boolean> {
return false;
}

export function getWindowPathExtensions(env = process.env) {
export function getWindowPathExtensions(env = processCommon.env) {
return (getCaseInsensitive(env, 'PATHEXT') as string || '.COM;.EXE;.BAT;.CMD').split(';');
}

export async function findExecutable(command: string, cwd?: string, paths?: string[], env: Platform.IProcessEnvironment = process.env as Platform.IProcessEnvironment, fileExists: (path: string) => Promise<boolean> = fileExistsDefault): Promise<string | undefined> {
export async function findExecutable(command: string, cwd?: string, paths?: string[], env: Platform.IProcessEnvironment = processCommon.env as Platform.IProcessEnvironment, fileExists: (path: string) => Promise<boolean> = fileExistsDefault): Promise<string | undefined> {
// If we have an absolute path then we take it.
if (path.isAbsolute(command)) {
return await fileExists(command) ? command : undefined;
}
if (cwd === undefined) {
cwd = process.cwd();
cwd = processCommon.cwd();
}
const dir = path.dirname(command);
if (dir !== '.') {
Expand Down Expand Up @@ -140,3 +142,42 @@ export async function findExecutable(command: string, cwd?: string, paths?: stri
const fullPath = path.join(cwd, command);
return await fileExists(fullPath) ? fullPath : undefined;
}

/**
* Kills a process and all its children.
* @param pid the process id to kill
* @param forceful whether to forcefully kill the process (default: false). Note
* that on Windows, terminal processes can _only_ be killed forcefully and this
* will throw when not forceful.
*/
export async function killTree(pid: number, forceful = false) {
let child: cp.ChildProcessByStdio<null, Stream.Readable, Stream.Readable>;
if (Platform.isWindows) {
const windir = process.env['WINDIR'] || 'C:\\Windows';
const taskKill = path.join(windir, 'System32', 'taskkill.exe');

const args = ['/T'];
if (forceful) {
args.push('/F');
}
args.push('/PID', String(pid));
child = cp.spawn(taskKill, args, { stdio: ['ignore', 'pipe', 'pipe'] });
} else {
const killScript = FileAccess.asFileUri('vs/base/node/terminateProcess.sh').fsPath;
child = cp.spawn('/bin/sh', [killScript, String(pid), forceful ? '9' : '15'], { stdio: ['ignore', 'pipe', 'pipe'] });
}

return new Promise<void>((resolve, reject) => {
const stdout: Buffer[] = [];
child.stdout.on('data', (data) => stdout.push(data));
child.stderr.on('data', (data) => stdout.push(data));
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`taskkill exited with code ${code}: ${Buffer.concat(stdout).toString()}`));
}
});
});
}
17 changes: 9 additions & 8 deletions src/vs/base/node/terminateProcess.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
#!/bin/bash
#!/bin/sh

ROOT_PID=$1
SIGNAL=$2

terminateTree() {
for cpid in $(/usr/bin/pgrep -P $1); do
terminateTree $cpid
done
kill -9 $1 > /dev/null 2>&1
for cpid in $(pgrep -P $1); do
terminateTree $cpid
done
kill -$SIGNAL $1 > /dev/null 2>&1
}

for pid in $*; do
terminateTree $pid
done
terminateTree $ROOT_PID
44 changes: 21 additions & 23 deletions src/vs/workbench/api/node/extHostMcpNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import { readFile } from 'fs/promises';
import { homedir } from 'os';
import { parseEnvFile } from '../../../base/common/envfile.js';
import { untildify } from '../../../base/common/labels.js';
import { DisposableMap } from '../../../base/common/lifecycle.js';
import * as path from '../../../base/common/path.js';
import { StreamSplitter } from '../../../base/node/nodeStreams.js';
import { findExecutable } from '../../../base/node/processes.js';
import { ILogService, LogLevel } from '../../../platform/log/common/log.js';
import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js';
import { McpStdioStateHandler } from '../../contrib/mcp/node/mcpStdioStateHandler.js';
import { IExtHostInitDataService } from '../common/extHostInitDataService.js';
import { ExtHostMcpService } from '../common/extHostMcp.js';
import { IExtHostRpcService } from '../common/extHostRpcService.js';
import * as path from '../../../base/common/path.js';
import { IExtHostInitDataService } from '../common/extHostInitDataService.js';

export class NodeExtHostMpcService extends ExtHostMcpService {
constructor(
Expand All @@ -26,10 +28,7 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
super(extHostRpc, logService, initDataService);
}

private nodeServers = new Map<number, {
abortCtrl: AbortController;
child: ChildProcessWithoutNullStreams;
}>();
private nodeServers = this._register(new DisposableMap<number, McpStdioStateHandler>());

protected override _startMcp(id: number, launch: McpServerLaunch): void {
if (launch.type === McpServerTransportType.Stdio) {
Expand All @@ -42,8 +41,7 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
override $stopMcp(id: number): void {
const nodeServer = this.nodeServers.get(id);
if (nodeServer) {
nodeServer.abortCtrl.abort();
this.nodeServers.delete(id);
nodeServer.stop(); // will get removed from map when process is fully stopped
} else {
super.$stopMcp(id);
}
Expand All @@ -52,7 +50,7 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
override $sendMessage(id: number, message: string): void {
const nodeServer = this.nodeServers.get(id);
if (nodeServer) {
nodeServer.child.stdin.write(message + '\n');
nodeServer.write(message);
} else {
super.$sendMessage(id, message);
}
Expand Down Expand Up @@ -82,7 +80,6 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
env[key] = value === null ? undefined : String(value);
}

const abortCtrl = new AbortController();
let child: ChildProcessWithoutNullStreams;
try {
const home = homedir();
Expand All @@ -102,16 +99,17 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
child = spawn(executable, args, {
stdio: 'pipe',
cwd,
signal: abortCtrl.signal,
env,
shell,
});
} catch (e) {
onError(e);
abortCtrl.abort();
return;
}

// Create the connection manager for graceful shutdown
const connectionManager = new McpStdioStateHandler(child);

this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting });

child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString()));
Expand All @@ -126,22 +124,22 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
child.on('spawn', () => this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Running }));

child.on('error', e => {
if (abortCtrl.signal.aborted) {
onError(e);
});
child.on('exit', code => {
this.nodeServers.deleteAndDispose(id);

if (code === 0 || connectionManager.stopped) {
this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped });
} else {
onError(e);
}
});
child.on('exit', code =>
code === 0 || abortCtrl.signal.aborted
? this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped })
: this._proxy.$onDidChangeState(id, {
this._proxy.$onDidChangeState(id, {
state: McpConnectionState.Kind.Error,
message: `Process exited with code ${code}`,
})
);
});
}
});

this.nodeServers.set(id, { abortCtrl, child });
this.nodeServers.set(id, connectionManager);
}
}

Expand Down
11 changes: 2 additions & 9 deletions src/vs/workbench/contrib/debug/node/debugAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as nls from '../../../../nls.js';
import { IExtensionDescription } from '../../../../platform/extensions/common/extensions.js';
import { IDebugAdapterExecutable, IDebugAdapterNamedPipeServer, IDebugAdapterServer, IDebuggerContribution, IPlatformSpecificAdapterContribution } from '../common/debug.js';
import { AbstractDebugAdapter } from '../common/abstractDebugAdapter.js';
import { killTree } from '../../../../base/node/processes.js';

/**
* An implementation that communicates via two streams with the debug adapter.
Expand Down Expand Up @@ -288,15 +289,7 @@ export class ExecutableDebugAdapter extends StreamDebugAdapter {
// processes. Therefore we use TASKKILL.EXE
await this.cancelPendingRequests();
if (platform.isWindows) {
return new Promise<void>((c, e) => {
const killer = cp.exec(`taskkill /F /T /PID ${this.serverProcess!.pid}`, function (err, stdout, stderr) {
if (err) {
return e(err);
}
});
killer.on('exit', c);
killer.on('error', e);
});
return killTree(this.serverProcess!.pid!, true);
} else {
this.serverProcess.kill('SIGTERM');
return Promise.resolve(undefined);
Expand Down
94 changes: 94 additions & 0 deletions src/vs/workbench/contrib/mcp/node/mcpStdioStateHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { ChildProcessWithoutNullStreams } from 'child_process';
import { TimeoutTimer } from '../../../../base/common/async.js';
import { IDisposable } from '../../../../base/common/lifecycle.js';
import { killTree } from '../../../../base/node/processes.js';
import { isWindows } from '../../../../base/common/platform.js';

const enum McpProcessState {
Running,
StdinEnded,
KilledPolite,
KilledForceful,
}

/**
* Manages graceful shutdown of MCP stdio connections following the MCP specification.
*
* Per spec, shutdown should:
* 1. Close the input stream to the child process
* 2. Wait for the server to exit, or send SIGTERM if it doesn't exit within 10 seconds
* 3. Send SIGKILL if the server doesn't exit within 10 seconds after SIGTERM
* 4. Allow forceful killing if called twice
*/
export class McpStdioStateHandler implements IDisposable {
private static readonly GRACE_TIME_MS = 10_000;

private _procState = McpProcessState.Running;
private _nextTimeout?: IDisposable;

public get stopped() {
return this._procState !== McpProcessState.Running;
}

constructor(
private readonly _child: ChildProcessWithoutNullStreams,
private readonly _graceTimeMs: number = McpStdioStateHandler.GRACE_TIME_MS
) { }

/**
* Initiates graceful shutdown. If called while shutdown is already in progress,
* forces immediate termination.
*/
public stop(): void {
if (this._procState === McpProcessState.Running) {
try {
this._child.stdin.end();
} catch (error) {
// If stdin.end() fails, continue with termination sequence
// This can happen if the stream is already in an error state
}
this._nextTimeout = new TimeoutTimer(() => this.killPolite(), this._graceTimeMs);
} else {
this._nextTimeout?.dispose();
this.killForceful();
}
}

private async killPolite() {
this._procState = McpProcessState.KilledPolite;
this._nextTimeout = new TimeoutTimer(() => this.killForceful(), this._graceTimeMs);

if (this._child.pid) {
if (!isWindows) {
await killTree(this._child.pid, false);
}
} else {
this._child.kill('SIGTERM');
}
}

private async killForceful() {
this._procState = McpProcessState.KilledForceful;

if (this._child.pid) {
await killTree(this._child.pid, true);
} else {
this._child.kill();
}
}

public write(message: string): void {
if (!this.stopped) {
this._child.stdin.write(message + '\n');
}
}

public dispose() {
this._nextTimeout?.dispose();
}
}
Loading
Loading