Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.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 { mapFindFirst } from '../../../../base/common/arraysFind.js';6import { assertNever } from '../../../../base/common/assert.js';7import { disposableTimeout } from '../../../../base/common/async.js';8import { parse as parseJsonc } from '../../../../base/common/jsonc.js';9import { DisposableStore } from '../../../../base/common/lifecycle.js';10import { Schemas } from '../../../../base/common/network.js';11import { autorun } from '../../../../base/common/observable.js';12import { basename } from '../../../../base/common/resources.js';13import { URI } from '../../../../base/common/uri.js';14import { generateUuid } from '../../../../base/common/uuid.js';15import { localize } from '../../../../nls.js';16import { ICommandService } from '../../../../platform/commands/common/commands.js';17import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';18import { IFileService } from '../../../../platform/files/common/files.js';19import { ILabelService } from '../../../../platform/label/common/label.js';20import { IGalleryMcpServerConfiguration, RegistryType } from '../../../../platform/mcp/common/mcpManagement.js';21import { IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpServerVariable, IMcpStdioServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js';22import { INotificationService } from '../../../../platform/notification/common/notification.js';23import { IOpenerService } from '../../../../platform/opener/common/opener.js';24import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js';25import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';26import { isWorkspaceFolder, IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js';27import { IEditorService } from '../../../services/editor/common/editorService.js';28import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';29import { IWorkbenchMcpManagementService } from '../../../services/mcp/common/mcpWorkbenchManagementService.js';30import { McpCommandIds } from '../common/mcpCommandIds.js';31import { allDiscoverySources, DiscoverySource, mcpDiscoverySection, mcpStdioServerSchema } from '../common/mcpConfiguration.js';32import { IMcpRegistry } from '../common/mcpRegistryTypes.js';33import { IMcpService, McpConnectionState } from '../common/mcpTypes.js';3435export const enum AddConfigurationType {36Stdio,37HTTP,3839NpmPackage,40PipPackage,41NuGetPackage,42DockerImage,43}4445type AssistedConfigurationType = AddConfigurationType.NpmPackage | AddConfigurationType.PipPackage | AddConfigurationType.NuGetPackage | AddConfigurationType.DockerImage;4647export const AssistedTypes = {48[AddConfigurationType.NpmPackage]: {49title: localize('mcp.npm.title', "Enter NPM Package Name"),50placeholder: localize('mcp.npm.placeholder', "Package name (e.g., @org/package)"),51pickLabel: localize('mcp.serverType.npm', "NPM Package"),52pickDescription: localize('mcp.serverType.npm.description', "Install from an NPM package name"),53enabledConfigKey: null, // always enabled54},55[AddConfigurationType.PipPackage]: {56title: localize('mcp.pip.title', "Enter Pip Package Name"),57placeholder: localize('mcp.pip.placeholder', "Package name (e.g., package-name)"),58pickLabel: localize('mcp.serverType.pip', "Pip Package"),59pickDescription: localize('mcp.serverType.pip.description', "Install from a Pip package name"),60enabledConfigKey: null, // always enabled61},62[AddConfigurationType.NuGetPackage]: {63title: localize('mcp.nuget.title', "Enter NuGet Package Name"),64placeholder: localize('mcp.nuget.placeholder', "Package name (e.g., Package.Name)"),65pickLabel: localize('mcp.serverType.nuget', "NuGet Package"),66pickDescription: localize('mcp.serverType.nuget.description', "Install from a NuGet package name"),67enabledConfigKey: 'chat.mcp.assisted.nuget.enabled',68},69[AddConfigurationType.DockerImage]: {70title: localize('mcp.docker.title', "Enter Docker Image Name"),71placeholder: localize('mcp.docker.placeholder', "Image name (e.g., mcp/imagename)"),72pickLabel: localize('mcp.serverType.docker', "Docker Image"),73pickDescription: localize('mcp.serverType.docker.description', "Install from a Docker image"),74enabledConfigKey: null, // always enabled75},76};7778const enum AddConfigurationCopilotCommand {79/** Returns whether MCP enhanced setup is enabled. */80IsSupported = 'github.copilot.chat.mcp.setup.check',8182/** Takes an npm/pip package name, validates its owner. */83ValidatePackage = 'github.copilot.chat.mcp.setup.validatePackage',8485/** Returns the resolved MCP configuration. */86StartFlow = 'github.copilot.chat.mcp.setup.flow',87}8889type ValidatePackageResult =90{ state: 'ok'; publisher: string; name?: string; version?: string }91| { state: 'error'; error: string; helpUri?: string; helpUriLabel?: string };9293type AddServerData = {94packageType: string;95};96type AddServerClassification = {97owner: 'digitarald';98comment: 'Generic details for adding a new MCP server';99packageType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of MCP server package' };100};101type AddServerCompletedData = {102packageType: string;103serverType: string | undefined;104target: string;105};106type AddServerCompletedClassification = {107owner: 'digitarald';108comment: 'Generic details for successfully adding model-assisted MCP server';109packageType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of MCP server package' };110serverType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of MCP server' };111target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The target of the MCP server configuration' };112};113114type AssistedServerConfiguration = {115type?: 'vscode';116name?: string;117server: Omit<IMcpStdioServerConfiguration, 'type'>;118inputs?: IMcpServerVariable[];119inputValues?: Record<string, string>;120} | {121type: 'server.json';122name?: string;123server: IGalleryMcpServerConfiguration;124};125126export class McpAddConfigurationCommand {127constructor(128private readonly workspaceFolder: IWorkspaceFolder | undefined,129@IQuickInputService private readonly _quickInputService: IQuickInputService,130@IWorkbenchMcpManagementService private readonly _mcpManagementService: IWorkbenchMcpManagementService,131@IWorkspaceContextService private readonly _workspaceService: IWorkspaceContextService,132@IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService,133@ICommandService private readonly _commandService: ICommandService,134@IMcpRegistry private readonly _mcpRegistry: IMcpRegistry,135@IOpenerService private readonly _openerService: IOpenerService,136@IEditorService private readonly _editorService: IEditorService,137@IFileService private readonly _fileService: IFileService,138@INotificationService private readonly _notificationService: INotificationService,139@ITelemetryService private readonly _telemetryService: ITelemetryService,140@IMcpService private readonly _mcpService: IMcpService,141@ILabelService private readonly _label: ILabelService,142@IConfigurationService private readonly _configurationService: IConfigurationService,143) { }144145private async getServerType(): Promise<AddConfigurationType | undefined> {146type TItem = { kind: AddConfigurationType | 'browse' | 'discovery' } & IQuickPickItem;147const items: QuickPickInput<TItem>[] = [148{ kind: AddConfigurationType.Stdio, label: localize('mcp.serverType.command', "Command (stdio)"), description: localize('mcp.serverType.command.description', "Run a local command that implements the MCP protocol") },149{ kind: AddConfigurationType.HTTP, label: localize('mcp.serverType.http', "HTTP (HTTP or Server-Sent Events)"), description: localize('mcp.serverType.http.description', "Connect to a remote HTTP server that implements the MCP protocol") }150];151152let aiSupported: boolean | undefined;153try {154aiSupported = await this._commandService.executeCommand<boolean>(AddConfigurationCopilotCommand.IsSupported);155} catch {156// ignored157}158159if (aiSupported) {160items.unshift({ type: 'separator', label: localize('mcp.serverType.manual', "Manual Install") });161162const elligableTypes = Object.entries(AssistedTypes).map(([type, { pickLabel, pickDescription, enabledConfigKey }]) => {163if (enabledConfigKey) {164const enabled = this._configurationService.getValue<boolean>(enabledConfigKey) ?? false;165if (!enabled) {166return;167}168}169return {170kind: Number(type) as AddConfigurationType,171label: pickLabel,172description: pickDescription,173};174}).filter(x => !!x);175176items.push(177{ type: 'separator', label: localize('mcp.serverType.copilot', "Model-Assisted") },178...elligableTypes179);180}181182items.push({ type: 'separator' });183184const discovery = this._configurationService.getValue<{ [K in DiscoverySource]: boolean }>(mcpDiscoverySection);185if (discovery && typeof discovery === 'object' && allDiscoverySources.some(d => !discovery[d])) {186items.push({187kind: 'discovery',188label: localize('mcp.servers.discovery', "Add from another application..."),189});190}191192items.push({193kind: 'browse',194label: localize('mcp.servers.browse', "Browse MCP Servers..."),195});196197const result = await this._quickInputService.pick<TItem>(items, {198placeHolder: localize('mcp.serverType.placeholder', "Choose the type of MCP server to add"),199});200201if (result?.kind === 'browse') {202this._commandService.executeCommand(McpCommandIds.Browse);203return undefined;204}205206if (result?.kind === 'discovery') {207this._commandService.executeCommand('workbench.action.openSettings', mcpDiscoverySection);208return undefined;209}210211return result?.kind;212}213214private async getStdioConfig(): Promise<IMcpStdioServerConfiguration | undefined> {215const command = await this._quickInputService.input({216title: localize('mcp.command.title', "Enter Command"),217placeHolder: localize('mcp.command.placeholder', "Command to run (with optional arguments)"),218ignoreFocusLost: true,219});220221if (!command) {222return undefined;223}224225this._telemetryService.publicLog2<AddServerData, AddServerClassification>('mcp.addserver', {226packageType: 'stdio'227});228229// Split command into command and args, handling quotes230const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g)!;231return {232type: McpServerType.LOCAL,233command: parts[0].replace(/"/g, ''),234235args: parts.slice(1).map(arg => arg.replace(/"/g, ''))236};237}238239private async getSSEConfig(): Promise<IMcpRemoteServerConfiguration | undefined> {240const url = await this._quickInputService.input({241title: localize('mcp.url.title', "Enter Server URL"),242placeHolder: localize('mcp.url.placeholder', "URL of the MCP server (e.g., http://localhost:3000)"),243ignoreFocusLost: true,244});245246if (!url) {247return undefined;248}249250this._telemetryService.publicLog2<AddServerData, AddServerClassification>('mcp.addserver', {251packageType: 'sse'252});253254return { url, type: McpServerType.REMOTE };255}256257private async getServerId(suggestion = `my-mcp-server-${generateUuid().split('-')[0]}`): Promise<string | undefined> {258const id = await this._quickInputService.input({259title: localize('mcp.serverId.title', "Enter Server ID"),260placeHolder: localize('mcp.serverId.placeholder', "Unique identifier for this server"),261value: suggestion,262ignoreFocusLost: true,263});264265return id;266}267268private async getConfigurationTarget(): Promise<ConfigurationTarget | IWorkspaceFolder | undefined> {269const options: (IQuickPickItem & { target?: ConfigurationTarget | IWorkspaceFolder })[] = [270{ target: ConfigurationTarget.USER_LOCAL, label: localize('mcp.target.user', "Global"), description: localize('mcp.target.user.description', "Available in all workspaces, runs locally") }271];272273const raLabel = this._environmentService.remoteAuthority && this._label.getHostLabel(Schemas.vscodeRemote, this._environmentService.remoteAuthority);274if (raLabel) {275options.push({ target: ConfigurationTarget.USER_REMOTE, label: localize('mcp.target.remote', "Remote"), description: localize('mcp.target..remote.description', "Available on this remote machine, runs on {0}", raLabel) });276}277278const workbenchState = this._workspaceService.getWorkbenchState();279if (workbenchState !== WorkbenchState.EMPTY) {280const target = workbenchState === WorkbenchState.FOLDER ? this._workspaceService.getWorkspace().folders[0] : ConfigurationTarget.WORKSPACE;281if (this._environmentService.remoteAuthority) {282options.push({ target, label: localize('mcp.target.workspace', "Workspace"), description: localize('mcp.target.workspace.description.remote', "Available in this workspace, runs on {0}", raLabel) });283} else {284options.push({ target, label: localize('mcp.target.workspace', "Workspace"), description: localize('mcp.target.workspace.description', "Available in this workspace, runs locally") });285}286}287288if (options.length === 1) {289return options[0].target;290}291292const targetPick = await this._quickInputService.pick(options, {293title: localize('mcp.target.title', "Choose where to install the MCP server"),294});295296return targetPick?.target;297}298299private async getAssistedConfig(type: AssistedConfigurationType): Promise<{ name?: string; server: Omit<IMcpStdioServerConfiguration, 'type'>; inputs?: IMcpServerVariable[]; inputValues?: Record<string, string> } | undefined> {300const packageName = await this._quickInputService.input({301ignoreFocusLost: true,302title: AssistedTypes[type].title,303placeHolder: AssistedTypes[type].placeholder,304});305306if (!packageName) {307return undefined;308}309310const enum LoadAction {311Retry = 'retry',312Cancel = 'cancel',313Allow = 'allow',314OpenUri = 'openUri',315}316317const loadingQuickPickStore = new DisposableStore();318const loadingQuickPick = loadingQuickPickStore.add(this._quickInputService.createQuickPick<IQuickPickItem & { id: LoadAction; helpUri?: URI }>());319loadingQuickPick.title = localize('mcp.loading.title', "Loading package details...");320loadingQuickPick.busy = true;321loadingQuickPick.ignoreFocusOut = true;322323const packageType = this.getPackageType(type);324325this._telemetryService.publicLog2<AddServerData, AddServerClassification>('mcp.addserver', {326packageType: packageType!327});328329this._commandService.executeCommand<ValidatePackageResult>(330AddConfigurationCopilotCommand.ValidatePackage,331{332type: packageType,333name: packageName,334targetConfig: {335...mcpStdioServerSchema,336properties: {337...mcpStdioServerSchema.properties,338name: {339type: 'string',340description: 'Suggested name of the server, alphanumeric and hyphen only',341}342},343required: [...(mcpStdioServerSchema.required || []), 'name'],344},345}346).then(result => {347if (!result || result.state === 'error') {348loadingQuickPick.title = result?.error || 'Unknown error loading package';349350const items: Array<IQuickPickItem & { id: LoadAction; helpUri?: URI }> = [];351352if (result?.helpUri) {353items.push({354id: LoadAction.OpenUri,355label: result.helpUriLabel ?? localize('mcp.error.openHelpUri', 'Open help URL'),356helpUri: URI.parse(result.helpUri),357});358}359360items.push(361{ id: LoadAction.Retry, label: localize('mcp.error.retry', 'Try a different package') },362{ id: LoadAction.Cancel, label: localize('cancel', 'Cancel') },363);364365loadingQuickPick.items = items;366} else {367loadingQuickPick.title = localize(368'mcp.confirmPublish', 'Install {0}{1} from {2}?',369result.name ?? packageName,370result.version ? `@${result.version}` : '',371result.publisher);372loadingQuickPick.items = [373{ id: LoadAction.Allow, label: localize('allow', "Allow") },374{ id: LoadAction.Cancel, label: localize('cancel', 'Cancel') }375];376}377loadingQuickPick.busy = false;378});379380const loadingAction = await new Promise<{ id: LoadAction; helpUri?: URI } | undefined>(resolve => {381loadingQuickPick.onDidAccept(() => resolve(loadingQuickPick.selectedItems[0]));382loadingQuickPick.onDidHide(() => resolve(undefined));383loadingQuickPick.show();384}).finally(() => loadingQuickPick.dispose());385386switch (loadingAction?.id) {387case LoadAction.Retry:388return this.getAssistedConfig(type);389case LoadAction.OpenUri:390if (loadingAction.helpUri) { this._openerService.open(loadingAction.helpUri); }391return undefined;392case LoadAction.Allow:393break;394case LoadAction.Cancel:395default:396return undefined;397}398399const config = await this._commandService.executeCommand<AssistedServerConfiguration>(400AddConfigurationCopilotCommand.StartFlow,401{402name: packageName,403type: packageType404}405);406407if (config?.type === 'server.json') {408const packageType = this.getPackageTypeEnum(type);409if (!packageType) {410throw new Error(`Unsupported assisted package type ${type}`);411}412const server = this._mcpManagementService.getMcpServerConfigurationFromManifest(config.server, packageType);413if (server.config.type !== McpServerType.LOCAL) {414throw new Error(`Unexpected server type ${server.config.type} for assisted configuration from server.json.`);415}416return {417name: config.name,418server: server.config,419inputs: server.inputs,420};421} else if (config?.type === 'vscode' || !config?.type) {422return config;423} else {424assertNever(config?.type);425}426}427428/** Shows the location of a server config once it's discovered. */429private showOnceDiscovered(name: string) {430const store = new DisposableStore();431store.add(autorun(reader => {432const colls = this._mcpRegistry.collections.read(reader);433const servers = this._mcpService.servers.read(reader);434const match = mapFindFirst(colls, collection => mapFindFirst(collection.serverDefinitions.read(reader),435server => server.label === name ? { server, collection } : undefined));436const server = match && servers.find(s => s.definition.id === match.server.id);437438439if (match && server) {440if (match.collection.presentation?.origin) {441this._editorService.openEditor({442resource: match.collection.presentation.origin,443options: {444selection: match.server.presentation?.origin?.range,445preserveFocus: true,446}447});448} else {449this._commandService.executeCommand(McpCommandIds.ServerOptions, name);450}451452server.start({ promptType: 'all-untrusted' }).then(state => {453if (state.state === McpConnectionState.Kind.Error) {454server.showOutput();455}456});457458store.dispose();459}460}));461462store.add(disposableTimeout(() => store.dispose(), 5000));463}464465public async run(): Promise<void> {466// Step 1: Choose server type467const serverType = await this.getServerType();468if (serverType === undefined) {469return;470}471472// Step 2: Get server details based on type473let config: IMcpServerConfiguration | undefined;474let suggestedName: string | undefined;475let inputs: IMcpServerVariable[] | undefined;476let inputValues: Record<string, string> | undefined;477switch (serverType) {478case AddConfigurationType.Stdio:479config = await this.getStdioConfig();480break;481case AddConfigurationType.HTTP:482config = await this.getSSEConfig();483break;484case AddConfigurationType.NpmPackage:485case AddConfigurationType.PipPackage:486case AddConfigurationType.NuGetPackage:487case AddConfigurationType.DockerImage: {488const r = await this.getAssistedConfig(serverType);489config = r?.server ? { ...r.server, type: McpServerType.LOCAL } : undefined;490suggestedName = r?.name;491inputs = r?.inputs;492inputValues = r?.inputValues;493break;494}495default:496assertNever(serverType);497}498499if (!config) {500return;501}502503// Step 3: Get server ID504const name = await this.getServerId(suggestedName);505if (!name) {506return;507}508509// Step 4: Choose configuration target if no configUri provided510let target: ConfigurationTarget | IWorkspaceFolder | undefined = this.workspaceFolder;511if (!target) {512target = await this.getConfigurationTarget();513if (!target) {514return;515}516}517518await this._mcpManagementService.install({ name, config, inputs }, { target });519520if (inputValues) {521for (const [key, value] of Object.entries(inputValues)) {522await this._mcpRegistry.setSavedInput(key, (isWorkspaceFolder(target) ? ConfigurationTarget.WORKSPACE_FOLDER : target) ?? ConfigurationTarget.WORKSPACE, value);523}524}525526const packageType = this.getPackageType(serverType);527if (packageType) {528this._telemetryService.publicLog2<AddServerCompletedData, AddServerCompletedClassification>('mcp.addserver.completed', {529packageType,530serverType: config.type,531target: target === ConfigurationTarget.WORKSPACE ? 'workspace' : 'user'532});533}534535this.showOnceDiscovered(name);536}537538public async pickForUrlHandler(resource: URI, showIsPrimary = false): Promise<void> {539const name = decodeURIComponent(basename(resource)).replace(/\.json$/, '');540const placeHolder = localize('install.title', 'Install MCP server {0}', name);541542const items: IQuickPickItem[] = [543{ id: 'install', label: localize('install.start', 'Install Server') },544{ id: 'show', label: localize('install.show', 'Show Configuration', name) },545{ id: 'rename', label: localize('install.rename', 'Rename "{0}"', name) },546{ id: 'cancel', label: localize('cancel', 'Cancel') },547];548if (showIsPrimary) {549[items[0], items[1]] = [items[1], items[0]];550}551552const pick = await this._quickInputService.pick(items, { placeHolder, ignoreFocusLost: true });553const getEditors = () => this._editorService.findEditors(resource);554555switch (pick?.id) {556case 'show':557await this._editorService.openEditor({ resource });558break;559case 'install':560await this._editorService.save(getEditors());561try {562const contents = await this._fileService.readFile(resource);563const { inputs, ...config }: IMcpServerConfiguration & { inputs?: IMcpServerVariable[] } = parseJsonc(contents.value.toString());564await this._mcpManagementService.install({ name, config, inputs });565this._editorService.closeEditors(getEditors());566this.showOnceDiscovered(name);567} catch (e) {568this._notificationService.error(localize('install.error', 'Error installing MCP server {0}: {1}', name, e.message));569await this._editorService.openEditor({ resource });570}571break;572case 'rename': {573const newName = await this._quickInputService.input({ placeHolder: localize('install.newName', 'Enter new name'), value: name });574if (newName) {575const newURI = resource.with({ path: `/${encodeURIComponent(newName)}.json` });576await this._editorService.save(getEditors());577await this._fileService.move(resource, newURI);578return this.pickForUrlHandler(newURI, showIsPrimary);579}580break;581}582}583}584585private getPackageTypeEnum(type: AddConfigurationType): RegistryType | undefined {586switch (type) {587case AddConfigurationType.NpmPackage:588return RegistryType.NODE;589case AddConfigurationType.PipPackage:590return RegistryType.PYTHON;591case AddConfigurationType.NuGetPackage:592return RegistryType.NUGET;593case AddConfigurationType.DockerImage:594return RegistryType.DOCKER;595default:596return undefined;597}598}599600private getPackageType(serverType: AddConfigurationType): string | undefined {601switch (serverType) {602case AddConfigurationType.NpmPackage:603return 'npm';604case AddConfigurationType.PipPackage:605return 'pip';606case AddConfigurationType.NuGetPackage:607return 'nuget';608case AddConfigurationType.DockerImage:609return 'docker';610case AddConfigurationType.Stdio:611return 'stdio';612case AddConfigurationType.HTTP:613return 'sse';614default:615return undefined;616}617}618}619620621