Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/api/node/extHostMcpNode.ts
5240 views
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, spawn } from 'child_process';
7
import { readFile } from 'fs/promises';
8
import { homedir } from 'os';
9
import type { RequestInit as UndiciRequestInit } from 'undici';
10
import { parseEnvFile } from '../../../base/common/envfile.js';
11
import { untildify } from '../../../base/common/labels.js';
12
import { Lazy } from '../../../base/common/lazy.js';
13
import { DisposableMap } from '../../../base/common/lifecycle.js';
14
import * as path from '../../../base/common/path.js';
15
import { URI } from '../../../base/common/uri.js';
16
import { StreamSplitter } from '../../../base/node/nodeStreams.js';
17
import { findExecutable } from '../../../base/node/processes.js';
18
import { LogLevel } from '../../../platform/log/common/log.js';
19
import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js';
20
import { McpStdioStateHandler } from '../../contrib/mcp/node/mcpStdioStateHandler.js';
21
import { CommonRequestInit, CommonResponse, ExtHostMcpService, McpHTTPHandle } from '../common/extHostMcp.js';
22
23
export class NodeExtHostMpcService extends ExtHostMcpService {
24
private nodeServers = this._register(new DisposableMap<number, McpStdioStateHandler>());
25
26
protected override _startMcp(id: number, launch: McpServerLaunch, defaultCwd?: URI, errorOnUserInteraction?: boolean): void {
27
if (launch.type === McpServerTransportType.Stdio) {
28
this.startNodeMpc(id, launch, defaultCwd);
29
} else if (launch.type === McpServerTransportType.HTTP) {
30
this._sseEventSources.set(id, new McpHTTPHandleNode(id, launch, this._proxy, this._logService, errorOnUserInteraction));
31
} else {
32
super._startMcp(id, launch, defaultCwd, errorOnUserInteraction);
33
}
34
}
35
36
override $stopMcp(id: number): void {
37
const nodeServer = this.nodeServers.get(id);
38
if (nodeServer) {
39
nodeServer.stop(); // will get removed from map when process is fully stopped
40
} else {
41
super.$stopMcp(id);
42
}
43
}
44
45
override $sendMessage(id: number, message: string): void {
46
const nodeServer = this.nodeServers.get(id);
47
if (nodeServer) {
48
nodeServer.write(message);
49
} else {
50
super.$sendMessage(id, message);
51
}
52
}
53
54
private async startNodeMpc(id: number, launch: McpServerTransportStdio, defaultCwd?: URI): Promise<void> {
55
const onError = (err: Error | string) => this._proxy.$onDidChangeState(id, {
56
state: McpConnectionState.Kind.Error,
57
// eslint-disable-next-line local/code-no-any-casts
58
code: err.hasOwnProperty('code') ? String((err as any).code) : undefined,
59
message: typeof err === 'string' ? err : err.message,
60
});
61
62
// MCP servers are run on the same authority where they are defined, so
63
// reading the envfile based on its path off the filesystem here is fine.
64
const env = { ...process.env };
65
if (launch.envFile) {
66
try {
67
for (const [key, value] of parseEnvFile(await readFile(launch.envFile, 'utf-8'))) {
68
env[key] = value;
69
}
70
} catch (e) {
71
onError(`Failed to read envFile '${launch.envFile}': ${e.message}`);
72
return;
73
}
74
}
75
for (const [key, value] of Object.entries(launch.env)) {
76
env[key] = value === null ? undefined : String(value);
77
}
78
79
let child: ChildProcessWithoutNullStreams;
80
try {
81
const home = homedir();
82
let cwd = launch.cwd ? untildify(launch.cwd, home) : (defaultCwd?.fsPath || home);
83
if (!path.isAbsolute(cwd)) {
84
cwd = defaultCwd ? path.join(defaultCwd.fsPath, cwd) : path.join(home, cwd);
85
}
86
87
const { executable, args, shell } = await formatSubprocessArguments(
88
untildify(launch.command, home),
89
launch.args.map(a => untildify(a, home)),
90
cwd,
91
env
92
);
93
94
this._proxy.$onDidPublishLog(id, LogLevel.Debug, `Server command line: ${executable} ${args.join(' ')}`);
95
child = spawn(executable, args, {
96
stdio: 'pipe',
97
cwd,
98
env,
99
shell,
100
});
101
} catch (e) {
102
onError(e);
103
return;
104
}
105
106
// Create the connection manager for graceful shutdown
107
const connectionManager = new McpStdioStateHandler(child);
108
109
this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting });
110
111
child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString()));
112
113
child.stdin.on('error', onError);
114
child.stdout.on('error', onError);
115
116
// Stderr handling is not currently specified https://github.com/modelcontextprotocol/specification/issues/177
117
// Just treat it as generic log data for now
118
child.stderr.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidPublishLog(id, LogLevel.Warning, `[server stderr] ${line.toString().trimEnd()}`));
119
120
child.on('spawn', () => this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Running }));
121
122
child.on('error', e => {
123
onError(e);
124
});
125
child.on('exit', code => {
126
this.nodeServers.deleteAndDispose(id);
127
128
if (code === 0 || connectionManager.stopped) {
129
this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped });
130
} else {
131
this._proxy.$onDidChangeState(id, {
132
state: McpConnectionState.Kind.Error,
133
message: `Process exited with code ${code}`,
134
});
135
}
136
});
137
138
this.nodeServers.set(id, connectionManager);
139
}
140
}
141
142
class McpHTTPHandleNode extends McpHTTPHandle {
143
private readonly _undici = new Lazy(() => import('undici'));
144
145
protected override async _fetchInternal(url: string, init?: CommonRequestInit): Promise<CommonResponse> {
146
// Note: imported async so that we can ensure we load undici after proxy patches have been applied
147
const { fetch, Agent } = await this._undici.value;
148
149
const undiciInit: UndiciRequestInit = { ...init };
150
151
let httpUrl = url;
152
const uri = URI.parse(url);
153
154
if (uri.scheme === 'unix' || uri.scheme === 'pipe') {
155
// By convention, we put the *socket path* as the URI path, and the *request path* in the fragment
156
// So, set the dispatcher with the socket path
157
undiciInit.dispatcher = new Agent({
158
socketPath: uri.path,
159
});
160
161
// And then rewrite the URL to be http://localhost/<fragment>
162
httpUrl = uri.with({
163
scheme: 'http',
164
authority: 'localhost', // HTTP always wants a host (not that we're using it), but if we're using a socket or pipe then localhost is sorta right anyway
165
path: uri.fragment,
166
}).toString(true);
167
} else {
168
return super._fetchInternal(url, init);
169
}
170
171
const undiciResponse = await fetch(httpUrl, undiciInit);
172
173
return {
174
status: undiciResponse.status,
175
statusText: undiciResponse.statusText,
176
headers: undiciResponse.headers,
177
body: undiciResponse.body as ReadableStream, // Way down in `ReadableStreamReadDoneResult<T>`, `value` is optional in the undici type but required (yet can be `undefined`) in the standard type
178
url: undiciResponse.url,
179
json: () => undiciResponse.json(),
180
text: () => undiciResponse.text(),
181
};
182
}
183
}
184
185
const windowsShellScriptRe = /\.(bat|cmd)$/i;
186
187
/**
188
* Formats arguments to avoid issues on Windows for CVE-2024-27980.
189
*/
190
export const formatSubprocessArguments = async (
191
executable: string,
192
args: ReadonlyArray<string>,
193
cwd: string | undefined,
194
env: Record<string, string | undefined>,
195
) => {
196
if (process.platform !== 'win32') {
197
return { executable, args, shell: false };
198
}
199
200
const found = await findExecutable(executable, cwd, undefined, env);
201
if (found && windowsShellScriptRe.test(found)) {
202
const quote = (s: string) => s.includes(' ') ? `"${s}"` : s;
203
return {
204
executable: quote(found),
205
args: args.map(quote),
206
shell: true,
207
};
208
}
209
210
return { executable, args, shell: false };
211
};
212
213