Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpDevMode.ts
5263 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 { equals as arraysEqual } from '../../../../base/common/arrays.js';6import { assertNever } from '../../../../base/common/assert.js';7import { Throttler } from '../../../../base/common/async.js';8import * as glob from '../../../../base/common/glob.js';9import { Disposable } from '../../../../base/common/lifecycle.js';10import { equals as objectsEqual } from '../../../../base/common/objects.js';11import { autorun, autorunDelta, derivedOpts } from '../../../../base/common/observable.js';12import { localize } from '../../../../nls.js';13import { ICommandService } from '../../../../platform/commands/common/commands.js';14import { FileSystemProviderCapabilities, IFileService } from '../../../../platform/files/common/files.js';15import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';16import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';17import { IConfig, IDebugService, IDebugSessionOptions } from '../../debug/common/debug.js';18import { IMcpRegistry } from './mcpRegistryTypes.js';19import { IMcpServer, McpServerDefinition, McpServerLaunch, McpServerTransportType } from './mcpTypes.js';2021export class McpDevModeServerAttache extends Disposable {22constructor(23server: IMcpServer,24fwdRef: { lastModeDebugged: boolean },25@IMcpRegistry registry: IMcpRegistry,26@IFileService fileService: IFileService,27@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,28) {29super();3031const workspaceFolder = server.readDefinitions().map(({ collection }) => collection?.presentation?.origin &&32workspaceContextService.getWorkspaceFolder(collection.presentation?.origin)?.uri);3334const restart = async () => {35const lastDebugged = fwdRef.lastModeDebugged;36await server.stop();37await server.start({ debug: lastDebugged });38};3940// 1. Auto-start the server, restart if entering debug mode41let didAutoStart = false;42this._register(autorun(reader => {43const defs = server.readDefinitions().read(reader);44if (!defs.collection || !defs.server || !defs.server.devMode) {45didAutoStart = false;46return;47}4849// don't keep trying to start the server unless it's a new server or devmode is newly turned on50if (didAutoStart) {51return;52}5354const delegates = registry.delegates.read(reader);55if (!delegates.some(d => d.canStart(defs.collection!, defs.server!))) {56return;57}5859server.start();60didAutoStart = true;61}));6263const debugMode = server.readDefinitions().map(d => !!d.server?.devMode?.debug);64this._register(autorunDelta(debugMode, ({ lastValue, newValue }) => {65if (!!newValue && !objectsEqual(lastValue, newValue)) {66restart();67}68}));6970// 2. Watch for file changes71const watchObs = derivedOpts<string[] | undefined>({ equalsFn: arraysEqual }, reader => {72const def = server.readDefinitions().read(reader);73const watch = def.server?.devMode?.watch;74return typeof watch === 'string' ? [watch] : watch;75});7677const restartScheduler = this._register(new Throttler());7879this._register(autorun(reader => {80const pattern = watchObs.read(reader);81const wf = workspaceFolder.read(reader);82if (!pattern || !wf) {83return;84}8586const includes = pattern.filter(p => !p.startsWith('!'));87const excludes = pattern.filter(p => p.startsWith('!')).map(p => p.slice(1));88reader.store.add(fileService.watch(wf, { includes, excludes, recursive: true }));8990const ignoreCase = !fileService.hasCapability(wf, FileSystemProviderCapabilities.PathCaseSensitive);91const includeParse = includes.map(p => glob.parse({ base: wf.fsPath, pattern: p }, { ignoreCase }));92const excludeParse = excludes.map(p => glob.parse({ base: wf.fsPath, pattern: p }, { ignoreCase }));93reader.store.add(fileService.onDidFilesChange(e => {94for (const change of [e.rawAdded, e.rawDeleted, e.rawUpdated]) {95for (const uri of change) {96if (includeParse.some(i => i(uri.fsPath)) && !excludeParse.some(e => e(uri.fsPath))) {97restartScheduler.queue(restart);98break;99}100}101}102}));103}));104}105}106107export interface IMcpDevModeDebugging {108readonly _serviceBrand: undefined;109110transform(definition: McpServerDefinition, launch: McpServerLaunch): Promise<McpServerLaunch>;111}112113export const IMcpDevModeDebugging = createDecorator<IMcpDevModeDebugging>('mcpDevModeDebugging');114115const DEBUG_HOST = '127.0.0.1';116117export class McpDevModeDebugging implements IMcpDevModeDebugging {118declare readonly _serviceBrand: undefined;119120constructor(121@IDebugService private readonly _debugService: IDebugService,122@ICommandService private readonly _commandService: ICommandService,123) { }124125public async transform(definition: McpServerDefinition, launch: McpServerLaunch): Promise<McpServerLaunch> {126if (!definition.devMode?.debug || launch.type !== McpServerTransportType.Stdio) {127return launch;128}129130const port = await this.getDebugPort();131const name = `MCP: ${definition.label}`; // for debugging132const options: IDebugSessionOptions = { startedByUser: false, suppressDebugView: true };133const commonConfig: Partial<IConfig> = {134internalConsoleOptions: 'neverOpen',135suppressMultipleSessionWarning: true,136};137138switch (definition.devMode.debug.type) {139case 'node': {140if (!/node[0-9]*$/.test(launch.command)) {141throw new Error(localize('mcp.debug.nodeBinReq', 'MCP server must be launched with the "node" executable to enable debugging, but was launched with "{0}"', launch.command));142}143144// We intentionally assert types as the DA has additional properties beyong IConfig145// eslint-disable-next-line local/code-no-dangerous-type-assertions146this._debugService.startDebugging(undefined, {147type: 'pwa-node',148request: 'attach',149name,150port,151host: DEBUG_HOST,152timeout: 30_000,153continueOnAttach: true,154...commonConfig,155} as IConfig, options);156return { ...launch, args: [`--inspect-brk=${DEBUG_HOST}:${port}`, ...launch.args] };157}158case 'debugpy': {159if (!/python[0-9.]*$/.test(launch.command)) {160throw new Error(localize('mcp.debug.pythonBinReq', 'MCP server must be launched with the "python" executable to enable debugging, but was launched with "{0}"', launch.command));161}162163let command: string | undefined;164let args = ['--wait-for-client', '--connect', `${DEBUG_HOST}:${port}`, ...launch.args];165if (definition.devMode.debug.debugpyPath) {166command = definition.devMode.debug.debugpyPath;167} else {168try {169// The Python debugger exposes a command to get its bundle debugpy module path. Use that if it's available.170const debugPyPath = await this._commandService.executeCommand<string | undefined>('python.getDebugpyPackagePath');171if (debugPyPath) {172command = launch.command;173args = [debugPyPath, ...args];174}175} catch {176// ignored, no Python debugger extension installed or an error therein177}178}179if (!command) {180command = 'debugpy';181}182183await Promise.race([184// eslint-disable-next-line local/code-no-dangerous-type-assertions185this._debugService.startDebugging(undefined, {186type: 'debugpy',187name,188request: 'attach',189listen: {190host: DEBUG_HOST,191port192},193...commonConfig,194} as IConfig, options),195this.ensureListeningOnPort(port)196]);197198return { ...launch, command, args };199}200default:201assertNever(definition.devMode.debug, `Unknown debug type ${JSON.stringify(definition.devMode.debug)}`);202}203}204205protected ensureListeningOnPort(port: number): Promise<void> {206return Promise.resolve();207}208209protected getDebugPort() {210return Promise.resolve(9230);211}212}213214215