Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpDevMode.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 { 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 { 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 {22public active: boolean = false;2324constructor(25server: IMcpServer,26fwdRef: { lastModeDebugged: boolean },27@IMcpRegistry registry: IMcpRegistry,28@IFileService fileService: IFileService,29@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,30) {31super();3233const workspaceFolder = server.readDefinitions().map(({ collection }) => collection?.presentation?.origin &&34workspaceContextService.getWorkspaceFolder(collection.presentation?.origin)?.uri);3536const restart = async () => {37const lastDebugged = fwdRef.lastModeDebugged;38await server.stop();39await server.start({ debug: lastDebugged });40};4142// 1. Auto-start the server, restart if entering debug mode43let didAutoStart = false;44this._register(autorun(reader => {45const defs = server.readDefinitions().read(reader);46if (!defs.collection || !defs.server || !defs.server.devMode) {47didAutoStart = false;48return;49}5051// don't keep trying to start the server unless it's a new server or devmode is newly turned on52if (didAutoStart) {53return;54}5556const delegates = registry.delegates.read(reader);57if (!delegates.some(d => d.canStart(defs.collection!, defs.server!))) {58return;59}6061server.start();62didAutoStart = true;63}));6465const debugMode = server.readDefinitions().map(d => !!d.server?.devMode?.debug);66this._register(autorunDelta(debugMode, ({ lastValue, newValue }) => {67if (!!newValue && !objectsEqual(lastValue, newValue)) {68restart();69}70}));7172// 2. Watch for file changes73const watchObs = derivedOpts<string[] | undefined>({ equalsFn: arraysEqual }, reader => {74const def = server.readDefinitions().read(reader);75const watch = def.server?.devMode?.watch;76return typeof watch === 'string' ? [watch] : watch;77});7879const restartScheduler = this._register(new Throttler());8081this._register(autorun(reader => {82const pattern = watchObs.read(reader);83const wf = workspaceFolder.read(reader);84if (!pattern || !wf) {85return;86}8788const includes = pattern.filter(p => !p.startsWith('!'));89const excludes = pattern.filter(p => p.startsWith('!')).map(p => p.slice(1));90reader.store.add(fileService.watch(wf, { includes, excludes, recursive: true }));9192const includeParse = includes.map(p => glob.parse({ base: wf.fsPath, pattern: p }));93const excludeParse = excludes.map(p => glob.parse({ base: wf.fsPath, pattern: p }));94reader.store.add(fileService.onDidFilesChange(e => {95for (const change of [e.rawAdded, e.rawDeleted, e.rawUpdated]) {96for (const uri of change) {97if (includeParse.some(i => i(uri.fsPath)) && !excludeParse.some(e => e(uri.fsPath))) {98restartScheduler.queue(restart);99break;100}101}102}103}));104}));105}106}107108export interface IMcpDevModeDebugging {109readonly _serviceBrand: undefined;110111transform(definition: McpServerDefinition, launch: McpServerLaunch): Promise<McpServerLaunch>;112}113114export const IMcpDevModeDebugging = createDecorator<IMcpDevModeDebugging>('mcpDevModeDebugging');115116const DEBUG_HOST = '127.0.0.1';117118export class McpDevModeDebugging implements IMcpDevModeDebugging {119declare readonly _serviceBrand: undefined;120121constructor(122@IDebugService private readonly _debugService: IDebugService,123@ICommandService private readonly _commandService: ICommandService,124) { }125126public async transform(definition: McpServerDefinition, launch: McpServerLaunch): Promise<McpServerLaunch> {127if (!definition.devMode?.debug || launch.type !== McpServerTransportType.Stdio) {128return launch;129}130131const port = await this.getDebugPort();132const name = `MCP: ${definition.label}`; // for debugging133const options: IDebugSessionOptions = { startedByUser: false, suppressDebugView: true };134const commonConfig: Partial<IConfig> = {135internalConsoleOptions: 'neverOpen',136suppressMultipleSessionWarning: true,137};138139switch (definition.devMode.debug.type) {140case 'node': {141if (!/node[0-9]*$/.test(launch.command)) {142throw 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));143}144145// We intentionally assert types as the DA has additional properties beyong IConfig146// eslint-disable-next-line local/code-no-dangerous-type-assertions147this._debugService.startDebugging(undefined, {148type: 'pwa-node',149request: 'attach',150name,151port,152host: DEBUG_HOST,153timeout: 30_000,154continueOnAttach: true,155...commonConfig,156} as IConfig, options);157return { ...launch, args: [`--inspect-brk=${DEBUG_HOST}:${port}`, ...launch.args] };158}159case 'debugpy': {160if (!/python[0-9.]*$/.test(launch.command)) {161throw 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));162}163164let command: string | undefined;165let args = ['--wait-for-client', '--connect', `${DEBUG_HOST}:${port}`, ...launch.args];166if (definition.devMode.debug.debugpyPath) {167command = definition.devMode.debug.debugpyPath;168} else {169try {170// The Python debugger exposes a command to get its bundle debugpy module path. Use that if it's available.171const debugPyPath = await this._commandService.executeCommand('python.getDebugpyPackagePath');172if (debugPyPath) {173command = launch.command;174args = [debugPyPath, ...args];175}176} catch {177// ignored, no Python debugger extension installed or an error therein178}179}180if (!command) {181command = 'debugpy';182}183184await Promise.race([185// eslint-disable-next-line local/code-no-dangerous-type-assertions186this._debugService.startDebugging(undefined, {187type: 'debugpy',188name,189request: 'attach',190listen: {191host: DEBUG_HOST,192port193},194...commonConfig,195} as IConfig, options),196this.ensureListeningOnPort(port)197]);198199return { ...launch, command, args };200}201default:202assertNever(definition.devMode.debug, `Unknown debug type ${JSON.stringify(definition.devMode.debug)}`);203}204}205206protected ensureListeningOnPort(port: number): Promise<void> {207return Promise.resolve();208}209210protected getDebugPort() {211return Promise.resolve(9230);212}213}214215216