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

217 LOC · 45 covered · 172 uncovered · 8 ranges · 1 concepts · 1 introducers · 6 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the fileextHostMcpNode.ts ×8 · 45 introduced LOCextHostMcpNode.ts ×8extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg does not add stray ^ to argument values|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/workbench/api/test/node/extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg does not add stray ^ to argument values|occurrence=1extHostMcpNode.test|titl…extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg doubles embedded double quotes (cmd.exe convention)|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/workbench/api/test/node/extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg doubles embedded double quotes (cmd.exe convention)|occurrence=1extHostMcpNode.test|titl…extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg neutralizes cmd.exe metacharacters inside quotes (CVE-2024-27980)|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/workbench/api/test/node/extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg neutralizes cmd.exe metacharacters inside quotes (CVE-2024-27980)|occurrence=1extHostMcpNode.test|titl…extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg preserves paths with parentheses without injecting ^|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/workbench/api/test/node/extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg preserves paths with parentheses without injecting ^|occurrence=1extHostMcpNode.test|titl…extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg preserves paths with spaces|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/workbench/api/test/node/extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg preserves paths with spaces|occurrence=1extHostMcpNode.test|titl…extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg wraps simple values in double quotes|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/workbench/api/test/node/extHostMcpNode.test|title=extHostMcpNode - escapeCmdArg wraps simple values in double quotes|occurrence=1extHostMcpNode.test|titl…Focused file · src/vs/workbench/api/node/extHostMcpNode.ts · 217 LOCnode/extHostMcpNode.ts

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- extHostMcpNode.ts ×8
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>());
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 }
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 }
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 }
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 // For PATH, we want to append to the existing PATH instead of overwriting it.
77 if (key.toUpperCase() === 'PATH' && value !== null) {
78 env[key] = env[key] ? `${env[key]}${path.delimiter}${String(value)}` : String(value);
79 continue;
80 }
81 env[key] = value === null ? undefined : String(value);
82 }
83
84 let child: ChildProcessWithoutNullStreams;
85 try {
86 const home = homedir();
87 let cwd = launch.cwd ? untildify(launch.cwd, home) : (defaultCwd?.fsPath || home);
88 if (!path.isAbsolute(cwd)) {
89 cwd = defaultCwd ? path.join(defaultCwd.fsPath, cwd) : path.join(home, cwd);
90 }
91
92 const { executable, args, shell } = await formatSubprocessArguments(
93 untildify(launch.command, home),
94 launch.args.map(a => untildify(a, home)),
95 cwd,
96 env
97 );
98
99 this._proxy.$onDidPublishLog(id, LogLevel.Debug, `Server command line: ${executable} ${args.join(' ')}`);
100 child = spawn(executable, args, {
101 stdio: 'pipe',
102 cwd,
103 env,
104 shell,
105 });
106 } catch (e) {
107 onError(e);
108 return;
109 }
110
111 // Create the connection manager for graceful shutdown
112 const connectionManager = new McpStdioStateHandler(child);
113
114 this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting });
115
116 child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString()));
117
118 child.stdin.on('error', onError);
119 child.stdout.on('error', onError);
120
121 // Stderr handling is not currently specified https://github.com/modelcontextprotocol/specification/issues/177
122 // Just treat it as generic log data for now
123 child.stderr.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidPublishLog(id, LogLevel.Warning, `[server stderr] ${line.toString().trimEnd()}`));
124
125 child.on('spawn', () => this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Running }));
126
127 child.on('error', e => {
128 onError(e);
129 });
130 child.on('exit', code => {
131 this.nodeServers.deleteAndDispose(id);
132
133 if (code === 0 || connectionManager.stopped) {
134 this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped });
135 } else {
136 this._proxy.$onDidChangeState(id, {
137 state: McpConnectionState.Kind.Error,
138 message: `Process exited with code ${code}`,
139 });
140 }
141 });
142
143 this.nodeServers.set(id, connectionManager);
144 }
146 >
147 class McpHTTPHandleNode extends McpHTTPHandle {
148 private readonly _undici = new Lazy(() => import('undici'));
150 > protected override async _fetchInternal(url: string, init?: CommonRequestInit): Promise<CommonResponse> {
151 // Note: imported async so that we can ensure we load undici after proxy patches have been applied
152 const { fetch, Agent } = await this._undici.value;
153
154 const undiciInit: UndiciRequestInit = { ...init };
155
156 let httpUrl = url;
157 const uri = URI.parse(url);
158
159 if (uri.scheme === 'unix' || uri.scheme === 'pipe') {
160 // By convention, we put the *socket path* as the URI path, and the *request path* in the fragment
161 // So, set the dispatcher with the socket path
162 undiciInit.dispatcher = new Agent({
163 socketPath: uri.path,
164 });
165
166 // And then rewrite the URL to be http://localhost/<fragment>
167 httpUrl = uri.with({
168 scheme: 'http',
169 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
170 path: uri.fragment,
171 }).toString(true);
172 } else {
173 return super._fetchInternal(url, init);
174 }
175
176 const undiciResponse = await fetch(httpUrl, undiciInit);
177
178 return {
179 status: undiciResponse.status,
180 statusText: undiciResponse.statusText,
181 headers: undiciResponse.headers as unknown as Headers, // undici `Headers` class no longer overlaps with lib.dom `Headers` (`SpecIterableIterator` vs `HeadersIterator`)
182 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
183 url: undiciResponse.url,
184 json: () => undiciResponse.json(),
185 text: () => undiciResponse.text(),
186 };
187 }
189 >
190 > const windowsShellScriptRe = /\.(bat|cmd)$/i;
191 >
192 > export const escapeCmdArg = (s: string): string => `"${s.replace(/"/g, '""')}"`;
193 >
194 > /**
195 > * Formats arguments to avoid issues on Windows for CVE-2024-27980.
196 > */
197 > export const formatSubprocessArguments = async (
198 executable: string,
199 args: ReadonlyArray<string>,
200 cwd: string | undefined,
201 env: Record<string, string | undefined>,
202 ) => {
203 if (process.platform !== 'win32') {
204 return { executable, args, shell: false };
205 }
206
207 const found = await findExecutable(executable, cwd, undefined, env);
208 if (found && windowsShellScriptRe.test(found)) {
209 return {
210 executable: escapeCmdArg(found),
211 args: args.map(escapeCmdArg),
212 shell: true,
213 };
214 }
215
216 return { executable, args, shell: false };
217 };