Skip to content

Commit 7e4e0f4

Browse files
Copilotconnor4312
andauthored
mcp: implement stdio shutdown spec compliance with graceful shutdown manager (microsoft#250207)
* Initial plan for issue * Implement MCP stdio shutdown spec compliance with graceful shutdown manager Co-authored-by: connor4312 <[email protected]> * Add error handling and cleanup optimizations to MCP shutdown manager Co-authored-by: connor4312 <[email protected]> * update * cleanup --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: connor4312 <[email protected]> Co-authored-by: Connor Peet <[email protected]>
1 parent 6d27939 commit 7e4e0f4

File tree

6 files changed

+268
-45
lines changed

6 files changed

+268
-45
lines changed

src/vs/base/node/processes.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,20 @@ import { Stats, promises } from 'fs';
88
import { getCaseInsensitive } from '../common/objects.js';
99
import * as path from '../common/path.js';
1010
import * as Platform from '../common/platform.js';
11-
import * as process from '../common/process.js';
11+
import * as processCommon from '../common/process.js';
1212
import { CommandOptions, ForkOptions, Source, SuccessData, TerminateResponse, TerminateResponseCode } from '../common/processes.js';
1313
import * as Types from '../common/types.js';
1414
import * as pfs from './pfs.js';
15+
import { FileAccess } from '../common/network.js';
16+
import Stream from 'stream';
1517
export { Source, TerminateResponseCode, type CommandOptions, type ForkOptions, type SuccessData, type TerminateResponse };
1618

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

2123

22-
export function getWindowsShell(env = process.env as Platform.IProcessEnvironment): string {
24+
export function getWindowsShell(env = processCommon.env as Platform.IProcessEnvironment): string {
2325
return env['comspec'] || 'cmd.exe';
2426
}
2527

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

84-
export function getWindowPathExtensions(env = process.env) {
86+
export function getWindowPathExtensions(env = processCommon.env) {
8587
return (getCaseInsensitive(env, 'PATHEXT') as string || '.COM;.EXE;.BAT;.CMD').split(';');
8688
}
8789

88-
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> {
90+
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> {
8991
// If we have an absolute path then we take it.
9092
if (path.isAbsolute(command)) {
9193
return await fileExists(command) ? command : undefined;
9294
}
9395
if (cwd === undefined) {
94-
cwd = process.cwd();
96+
cwd = processCommon.cwd();
9597
}
9698
const dir = path.dirname(command);
9799
if (dir !== '.') {
@@ -140,3 +142,42 @@ export async function findExecutable(command: string, cwd?: string, paths?: stri
140142
const fullPath = path.join(cwd, command);
141143
return await fileExists(fullPath) ? fullPath : undefined;
142144
}
145+
146+
/**
147+
* Kills a process and all its children.
148+
* @param pid the process id to kill
149+
* @param forceful whether to forcefully kill the process (default: false). Note
150+
* that on Windows, terminal processes can _only_ be killed forcefully and this
151+
* will throw when not forceful.
152+
*/
153+
export async function killTree(pid: number, forceful = false) {
154+
let child: cp.ChildProcessByStdio<null, Stream.Readable, Stream.Readable>;
155+
if (Platform.isWindows) {
156+
const windir = process.env['WINDIR'] || 'C:\\Windows';
157+
const taskKill = path.join(windir, 'System32', 'taskkill.exe');
158+
159+
const args = ['/T'];
160+
if (forceful) {
161+
args.push('/F');
162+
}
163+
args.push('/PID', String(pid));
164+
child = cp.spawn(taskKill, args, { stdio: ['ignore', 'pipe', 'pipe'] });
165+
} else {
166+
const killScript = FileAccess.asFileUri('vs/base/node/terminateProcess.sh').fsPath;
167+
child = cp.spawn('/bin/sh', [killScript, String(pid), forceful ? '9' : '15'], { stdio: ['ignore', 'pipe', 'pipe'] });
168+
}
169+
170+
return new Promise<void>((resolve, reject) => {
171+
const stdout: Buffer[] = [];
172+
child.stdout.on('data', (data) => stdout.push(data));
173+
child.stderr.on('data', (data) => stdout.push(data));
174+
child.on('error', reject);
175+
child.on('exit', (code) => {
176+
if (code === 0) {
177+
resolve();
178+
} else {
179+
reject(new Error(`taskkill exited with code ${code}: ${Buffer.concat(stdout).toString()}`));
180+
}
181+
});
182+
});
183+
}
Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
#!/bin/bash
1+
#!/bin/sh
2+
3+
ROOT_PID=$1
4+
SIGNAL=$2
25

36
terminateTree() {
4-
for cpid in $(/usr/bin/pgrep -P $1); do
5-
terminateTree $cpid
6-
done
7-
kill -9 $1 > /dev/null 2>&1
7+
for cpid in $(pgrep -P $1); do
8+
terminateTree $cpid
9+
done
10+
kill -$SIGNAL $1 > /dev/null 2>&1
811
}
912

10-
for pid in $*; do
11-
terminateTree $pid
12-
done
13+
terminateTree $ROOT_PID

src/vs/workbench/api/node/extHostMcpNode.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ import { readFile } from 'fs/promises';
88
import { homedir } from 'os';
99
import { parseEnvFile } from '../../../base/common/envfile.js';
1010
import { untildify } from '../../../base/common/labels.js';
11+
import { DisposableMap } from '../../../base/common/lifecycle.js';
12+
import * as path from '../../../base/common/path.js';
1113
import { StreamSplitter } from '../../../base/node/nodeStreams.js';
1214
import { findExecutable } from '../../../base/node/processes.js';
1315
import { ILogService, LogLevel } from '../../../platform/log/common/log.js';
1416
import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js';
17+
import { McpStdioStateHandler } from '../../contrib/mcp/node/mcpStdioStateHandler.js';
18+
import { IExtHostInitDataService } from '../common/extHostInitDataService.js';
1519
import { ExtHostMcpService } from '../common/extHostMcp.js';
1620
import { IExtHostRpcService } from '../common/extHostRpcService.js';
17-
import * as path from '../../../base/common/path.js';
18-
import { IExtHostInitDataService } from '../common/extHostInitDataService.js';
1921

2022
export class NodeExtHostMpcService extends ExtHostMcpService {
2123
constructor(
@@ -26,10 +28,7 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
2628
super(extHostRpc, logService, initDataService);
2729
}
2830

29-
private nodeServers = new Map<number, {
30-
abortCtrl: AbortController;
31-
child: ChildProcessWithoutNullStreams;
32-
}>();
31+
private nodeServers = this._register(new DisposableMap<number, McpStdioStateHandler>());
3332

3433
protected override _startMcp(id: number, launch: McpServerLaunch): void {
3534
if (launch.type === McpServerTransportType.Stdio) {
@@ -42,8 +41,7 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
4241
override $stopMcp(id: number): void {
4342
const nodeServer = this.nodeServers.get(id);
4443
if (nodeServer) {
45-
nodeServer.abortCtrl.abort();
46-
this.nodeServers.delete(id);
44+
nodeServer.stop(); // will get removed from map when process is fully stopped
4745
} else {
4846
super.$stopMcp(id);
4947
}
@@ -52,7 +50,7 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
5250
override $sendMessage(id: number, message: string): void {
5351
const nodeServer = this.nodeServers.get(id);
5452
if (nodeServer) {
55-
nodeServer.child.stdin.write(message + '\n');
53+
nodeServer.write(message);
5654
} else {
5755
super.$sendMessage(id, message);
5856
}
@@ -82,7 +80,6 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
8280
env[key] = value === null ? undefined : String(value);
8381
}
8482

85-
const abortCtrl = new AbortController();
8683
let child: ChildProcessWithoutNullStreams;
8784
try {
8885
const home = homedir();
@@ -102,16 +99,17 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
10299
child = spawn(executable, args, {
103100
stdio: 'pipe',
104101
cwd,
105-
signal: abortCtrl.signal,
106102
env,
107103
shell,
108104
});
109105
} catch (e) {
110106
onError(e);
111-
abortCtrl.abort();
112107
return;
113108
}
114109

110+
// Create the connection manager for graceful shutdown
111+
const connectionManager = new McpStdioStateHandler(child);
112+
115113
this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting });
116114

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

128126
child.on('error', e => {
129-
if (abortCtrl.signal.aborted) {
127+
onError(e);
128+
});
129+
child.on('exit', code => {
130+
this.nodeServers.deleteAndDispose(id);
131+
132+
if (code === 0 || connectionManager.stopped) {
130133
this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped });
131134
} else {
132-
onError(e);
133-
}
134-
});
135-
child.on('exit', code =>
136-
code === 0 || abortCtrl.signal.aborted
137-
? this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped })
138-
: this._proxy.$onDidChangeState(id, {
135+
this._proxy.$onDidChangeState(id, {
139136
state: McpConnectionState.Kind.Error,
140137
message: `Process exited with code ${code}`,
141-
})
142-
);
138+
});
139+
}
140+
});
143141

144-
this.nodeServers.set(id, { abortCtrl, child });
142+
this.nodeServers.set(id, connectionManager);
145143
}
146144
}
147145

src/vs/workbench/contrib/debug/node/debugAdapter.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as nls from '../../../../nls.js';
1515
import { IExtensionDescription } from '../../../../platform/extensions/common/extensions.js';
1616
import { IDebugAdapterExecutable, IDebugAdapterNamedPipeServer, IDebugAdapterServer, IDebuggerContribution, IPlatformSpecificAdapterContribution } from '../common/debug.js';
1717
import { AbstractDebugAdapter } from '../common/abstractDebugAdapter.js';
18+
import { killTree } from '../../../../base/node/processes.js';
1819

1920
/**
2021
* An implementation that communicates via two streams with the debug adapter.
@@ -288,15 +289,7 @@ export class ExecutableDebugAdapter extends StreamDebugAdapter {
288289
// processes. Therefore we use TASKKILL.EXE
289290
await this.cancelPendingRequests();
290291
if (platform.isWindows) {
291-
return new Promise<void>((c, e) => {
292-
const killer = cp.exec(`taskkill /F /T /PID ${this.serverProcess!.pid}`, function (err, stdout, stderr) {
293-
if (err) {
294-
return e(err);
295-
}
296-
});
297-
killer.on('exit', c);
298-
killer.on('error', e);
299-
});
292+
return killTree(this.serverProcess!.pid!, true);
300293
} else {
301294
this.serverProcess.kill('SIGTERM');
302295
return Promise.resolve(undefined);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { ChildProcessWithoutNullStreams } from 'child_process';
7+
import { TimeoutTimer } from '../../../../base/common/async.js';
8+
import { IDisposable } from '../../../../base/common/lifecycle.js';
9+
import { killTree } from '../../../../base/node/processes.js';
10+
import { isWindows } from '../../../../base/common/platform.js';
11+
12+
const enum McpProcessState {
13+
Running,
14+
StdinEnded,
15+
KilledPolite,
16+
KilledForceful,
17+
}
18+
19+
/**
20+
* Manages graceful shutdown of MCP stdio connections following the MCP specification.
21+
*
22+
* Per spec, shutdown should:
23+
* 1. Close the input stream to the child process
24+
* 2. Wait for the server to exit, or send SIGTERM if it doesn't exit within 10 seconds
25+
* 3. Send SIGKILL if the server doesn't exit within 10 seconds after SIGTERM
26+
* 4. Allow forceful killing if called twice
27+
*/
28+
export class McpStdioStateHandler implements IDisposable {
29+
private static readonly GRACE_TIME_MS = 10_000;
30+
31+
private _procState = McpProcessState.Running;
32+
private _nextTimeout?: IDisposable;
33+
34+
public get stopped() {
35+
return this._procState !== McpProcessState.Running;
36+
}
37+
38+
constructor(
39+
private readonly _child: ChildProcessWithoutNullStreams,
40+
private readonly _graceTimeMs: number = McpStdioStateHandler.GRACE_TIME_MS
41+
) { }
42+
43+
/**
44+
* Initiates graceful shutdown. If called while shutdown is already in progress,
45+
* forces immediate termination.
46+
*/
47+
public stop(): void {
48+
if (this._procState === McpProcessState.Running) {
49+
try {
50+
this._child.stdin.end();
51+
} catch (error) {
52+
// If stdin.end() fails, continue with termination sequence
53+
// This can happen if the stream is already in an error state
54+
}
55+
this._nextTimeout = new TimeoutTimer(() => this.killPolite(), this._graceTimeMs);
56+
} else {
57+
this._nextTimeout?.dispose();
58+
this.killForceful();
59+
}
60+
}
61+
62+
private async killPolite() {
63+
this._procState = McpProcessState.KilledPolite;
64+
this._nextTimeout = new TimeoutTimer(() => this.killForceful(), this._graceTimeMs);
65+
66+
if (this._child.pid) {
67+
if (!isWindows) {
68+
await killTree(this._child.pid, false);
69+
}
70+
} else {
71+
this._child.kill('SIGTERM');
72+
}
73+
}
74+
75+
private async killForceful() {
76+
this._procState = McpProcessState.KilledForceful;
77+
78+
if (this._child.pid) {
79+
await killTree(this._child.pid, true);
80+
} else {
81+
this._child.kill();
82+
}
83+
}
84+
85+
public write(message: string): void {
86+
if (!this.stopped) {
87+
this._child.stdin.write(message + '\n');
88+
}
89+
}
90+
91+
public dispose() {
92+
this._nextTimeout?.dispose();
93+
}
94+
}

0 commit comments

Comments
 (0)