Path: blob/main/src/vs/workbench/api/node/extHostMcpNode.ts
3296 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 { parseEnvFile } from '../../../base/common/envfile.js';9import { untildify } from '../../../base/common/labels.js';10import { DisposableMap } from '../../../base/common/lifecycle.js';11import * as path from '../../../base/common/path.js';12import { StreamSplitter } from '../../../base/node/nodeStreams.js';13import { findExecutable } from '../../../base/node/processes.js';14import { ILogService, LogLevel } from '../../../platform/log/common/log.js';15import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js';16import { McpStdioStateHandler } from '../../contrib/mcp/node/mcpStdioStateHandler.js';17import { IExtHostInitDataService } from '../common/extHostInitDataService.js';18import { ExtHostMcpService } from '../common/extHostMcp.js';19import { IExtHostRpcService } from '../common/extHostRpcService.js';2021export class NodeExtHostMpcService extends ExtHostMcpService {22constructor(23@IExtHostRpcService extHostRpc: IExtHostRpcService,24@IExtHostInitDataService initDataService: IExtHostInitDataService,25@ILogService logService: ILogService,26) {27super(extHostRpc, logService, initDataService);28}2930private nodeServers = this._register(new DisposableMap<number, McpStdioStateHandler>());3132protected override _startMcp(id: number, launch: McpServerLaunch): void {33if (launch.type === McpServerTransportType.Stdio) {34this.startNodeMpc(id, launch);35} else {36super._startMcp(id, launch);37}38}3940override $stopMcp(id: number): void {41const nodeServer = this.nodeServers.get(id);42if (nodeServer) {43nodeServer.stop(); // will get removed from map when process is fully stopped44} else {45super.$stopMcp(id);46}47}4849override $sendMessage(id: number, message: string): void {50const nodeServer = this.nodeServers.get(id);51if (nodeServer) {52nodeServer.write(message);53} else {54super.$sendMessage(id, message);55}56}5758private async startNodeMpc(id: number, launch: McpServerTransportStdio) {59const onError = (err: Error | string) => this._proxy.$onDidChangeState(id, {60state: McpConnectionState.Kind.Error,61code: err.hasOwnProperty('code') ? String((err as any).code) : undefined,62message: typeof err === 'string' ? err : err.message,63});6465// MCP servers are run on the same authority where they are defined, so66// reading the envfile based on its path off the filesystem here is fine.67const env = { ...process.env };68if (launch.envFile) {69try {70for (const [key, value] of parseEnvFile(await readFile(launch.envFile, 'utf-8'))) {71env[key] = value;72}73} catch (e) {74onError(`Failed to read envFile '${launch.envFile}': ${e.message}`);75return;76}77}78for (const [key, value] of Object.entries(launch.env)) {79env[key] = value === null ? undefined : String(value);80}8182let child: ChildProcessWithoutNullStreams;83try {84const home = homedir();85let cwd = launch.cwd ? untildify(launch.cwd, home) : home;86if (!path.isAbsolute(cwd)) {87cwd = path.join(home, cwd);88}8990const { executable, args, shell } = await formatSubprocessArguments(91untildify(launch.command, home),92launch.args.map(a => untildify(a, home)),93cwd,94env95);9697this._proxy.$onDidPublishLog(id, LogLevel.Debug, `Server command line: ${executable} ${args.join(' ')}`);98child = spawn(executable, args, {99stdio: 'pipe',100cwd,101env,102shell,103});104} catch (e) {105onError(e);106return;107}108109// Create the connection manager for graceful shutdown110const connectionManager = new McpStdioStateHandler(child);111112this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting });113114child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString()));115116child.stdin.on('error', onError);117child.stdout.on('error', onError);118119// Stderr handling is not currently specified https://github.com/modelcontextprotocol/specification/issues/177120// Just treat it as generic log data for now121child.stderr.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidPublishLog(id, LogLevel.Warning, `[server stderr] ${line.toString().trimEnd()}`));122123child.on('spawn', () => this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Running }));124125child.on('error', e => {126onError(e);127});128child.on('exit', code => {129this.nodeServers.deleteAndDispose(id);130131if (code === 0 || connectionManager.stopped) {132this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped });133} else {134this._proxy.$onDidChangeState(id, {135state: McpConnectionState.Kind.Error,136message: `Process exited with code ${code}`,137});138}139});140141this.nodeServers.set(id, connectionManager);142}143}144145const windowsShellScriptRe = /\.(bat|cmd)$/i;146147/**148* Formats arguments to avoid issues on Windows for CVE-2024-27980.149*/150export const formatSubprocessArguments = async (151executable: string,152args: ReadonlyArray<string>,153cwd: string | undefined,154env: Record<string, string | undefined>,155) => {156if (process.platform !== 'win32') {157return { executable, args, shell: false };158}159160const found = await findExecutable(executable, cwd, undefined, env);161if (found && windowsShellScriptRe.test(found)) {162const quote = (s: string) => s.includes(' ') ? `"${s}"` : s;163return {164executable: quote(found),165args: args.map(quote),166shell: true,167};168}169170return { executable, args, shell: false };171};172173174