Path: blob/main/src/vs/workbench/api/node/extHostMcpNode.ts
5240 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { ChildProcessWithoutNullStreams, spawn } from 'child_process';6import { readFile } from 'fs/promises';7import { homedir } from 'os';8import type { RequestInit as UndiciRequestInit } from 'undici';9import { parseEnvFile } from '../../../base/common/envfile.js';10import { untildify } from '../../../base/common/labels.js';11import { Lazy } from '../../../base/common/lazy.js';12import { DisposableMap } from '../../../base/common/lifecycle.js';13import * as path from '../../../base/common/path.js';14import { URI } from '../../../base/common/uri.js';15import { StreamSplitter } from '../../../base/node/nodeStreams.js';16import { findExecutable } from '../../../base/node/processes.js';17import { LogLevel } from '../../../platform/log/common/log.js';18import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js';19import { McpStdioStateHandler } from '../../contrib/mcp/node/mcpStdioStateHandler.js';20import { CommonRequestInit, CommonResponse, ExtHostMcpService, McpHTTPHandle } from '../common/extHostMcp.js';2122export class NodeExtHostMpcService extends ExtHostMcpService {23private nodeServers = this._register(new DisposableMap<number, McpStdioStateHandler>());2425protected override _startMcp(id: number, launch: McpServerLaunch, defaultCwd?: URI, errorOnUserInteraction?: boolean): void {26if (launch.type === McpServerTransportType.Stdio) {27this.startNodeMpc(id, launch, defaultCwd);28} else if (launch.type === McpServerTransportType.HTTP) {29this._sseEventSources.set(id, new McpHTTPHandleNode(id, launch, this._proxy, this._logService, errorOnUserInteraction));30} else {31super._startMcp(id, launch, defaultCwd, errorOnUserInteraction);32}33}3435override $stopMcp(id: number): void {36const nodeServer = this.nodeServers.get(id);37if (nodeServer) {38nodeServer.stop(); // will get removed from map when process is fully stopped39} else {40super.$stopMcp(id);41}42}4344override $sendMessage(id: number, message: string): void {45const nodeServer = this.nodeServers.get(id);46if (nodeServer) {47nodeServer.write(message);48} else {49super.$sendMessage(id, message);50}51}5253private async startNodeMpc(id: number, launch: McpServerTransportStdio, defaultCwd?: URI): Promise<void> {54const onError = (err: Error | string) => this._proxy.$onDidChangeState(id, {55state: McpConnectionState.Kind.Error,56// eslint-disable-next-line local/code-no-any-casts57code: err.hasOwnProperty('code') ? String((err as any).code) : undefined,58message: typeof err === 'string' ? err : err.message,59});6061// MCP servers are run on the same authority where they are defined, so62// reading the envfile based on its path off the filesystem here is fine.63const env = { ...process.env };64if (launch.envFile) {65try {66for (const [key, value] of parseEnvFile(await readFile(launch.envFile, 'utf-8'))) {67env[key] = value;68}69} catch (e) {70onError(`Failed to read envFile '${launch.envFile}': ${e.message}`);71return;72}73}74for (const [key, value] of Object.entries(launch.env)) {75env[key] = value === null ? undefined : String(value);76}7778let child: ChildProcessWithoutNullStreams;79try {80const home = homedir();81let cwd = launch.cwd ? untildify(launch.cwd, home) : (defaultCwd?.fsPath || home);82if (!path.isAbsolute(cwd)) {83cwd = defaultCwd ? path.join(defaultCwd.fsPath, cwd) : path.join(home, cwd);84}8586const { executable, args, shell } = await formatSubprocessArguments(87untildify(launch.command, home),88launch.args.map(a => untildify(a, home)),89cwd,90env91);9293this._proxy.$onDidPublishLog(id, LogLevel.Debug, `Server command line: ${executable} ${args.join(' ')}`);94child = spawn(executable, args, {95stdio: 'pipe',96cwd,97env,98shell,99});100} catch (e) {101onError(e);102return;103}104105// Create the connection manager for graceful shutdown106const connectionManager = new McpStdioStateHandler(child);107108this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting });109110child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString()));111112child.stdin.on('error', onError);113child.stdout.on('error', onError);114115// Stderr handling is not currently specified https://github.com/modelcontextprotocol/specification/issues/177116// Just treat it as generic log data for now117child.stderr.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidPublishLog(id, LogLevel.Warning, `[server stderr] ${line.toString().trimEnd()}`));118119child.on('spawn', () => this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Running }));120121child.on('error', e => {122onError(e);123});124child.on('exit', code => {125this.nodeServers.deleteAndDispose(id);126127if (code === 0 || connectionManager.stopped) {128this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped });129} else {130this._proxy.$onDidChangeState(id, {131state: McpConnectionState.Kind.Error,132message: `Process exited with code ${code}`,133});134}135});136137this.nodeServers.set(id, connectionManager);138}139}140141class McpHTTPHandleNode extends McpHTTPHandle {142private readonly _undici = new Lazy(() => import('undici'));143144protected override async _fetchInternal(url: string, init?: CommonRequestInit): Promise<CommonResponse> {145// Note: imported async so that we can ensure we load undici after proxy patches have been applied146const { fetch, Agent } = await this._undici.value;147148const undiciInit: UndiciRequestInit = { ...init };149150let httpUrl = url;151const uri = URI.parse(url);152153if (uri.scheme === 'unix' || uri.scheme === 'pipe') {154// By convention, we put the *socket path* as the URI path, and the *request path* in the fragment155// So, set the dispatcher with the socket path156undiciInit.dispatcher = new Agent({157socketPath: uri.path,158});159160// And then rewrite the URL to be http://localhost/<fragment>161httpUrl = uri.with({162scheme: 'http',163authority: '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 anyway164path: uri.fragment,165}).toString(true);166} else {167return super._fetchInternal(url, init);168}169170const undiciResponse = await fetch(httpUrl, undiciInit);171172return {173status: undiciResponse.status,174statusText: undiciResponse.statusText,175headers: undiciResponse.headers,176body: 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 type177url: undiciResponse.url,178json: () => undiciResponse.json(),179text: () => undiciResponse.text(),180};181}182}183184const windowsShellScriptRe = /\.(bat|cmd)$/i;185186/**187* Formats arguments to avoid issues on Windows for CVE-2024-27980.188*/189export const formatSubprocessArguments = async (190executable: string,191args: ReadonlyArray<string>,192cwd: string | undefined,193env: Record<string, string | undefined>,194) => {195if (process.platform !== 'win32') {196return { executable, args, shell: false };197}198199const found = await findExecutable(executable, cwd, undefined, env);200if (found && windowsShellScriptRe.test(found)) {201const quote = (s: string) => s.includes(' ') ? `"${s}"` : s;202return {203executable: quote(found),204args: args.map(quote),205shell: true,206};207}208209return { executable, args, shell: false };210};211212213