Path: blob/main/src/vs/platform/mcp/common/mcpManagementService.ts
3294 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 { RunOnceScheduler } from '../../../base/common/async.js';6import { VSBuffer } from '../../../base/common/buffer.js';7import { CancellationToken } from '../../../base/common/cancellation.js';8import { Emitter, Event } from '../../../base/common/event.js';9import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js';10import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';11import { ResourceMap } from '../../../base/common/map.js';12import { equals } from '../../../base/common/objects.js';13import { isString } from '../../../base/common/types.js';14import { URI } from '../../../base/common/uri.js';15import { localize } from '../../../nls.js';16import { ConfigurationTarget } from '../../configuration/common/configuration.js';17import { IEnvironmentService } from '../../environment/common/environment.js';18import { IFileService } from '../../files/common/files.js';19import { IInstantiationService } from '../../instantiation/common/instantiation.js';20import { ILogService } from '../../log/common/log.js';21import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js';22import { IUserDataProfilesService } from '../../userDataProfile/common/userDataProfile.js';23import { DidUninstallMcpServerEvent, IGalleryMcpServer, ILocalMcpServer, IMcpGalleryService, IMcpManagementService, IMcpServerInput, IGalleryMcpServerConfiguration, InstallMcpServerEvent, InstallMcpServerResult, RegistryType, UninstallMcpServerEvent, InstallOptions, UninstallOptions, IInstallableMcpServer, IAllowedMcpServersService } from './mcpManagement.js';24import { IMcpServerVariable, McpServerVariableType, IMcpServerConfiguration, McpServerType } from './mcpPlatformTypes.js';25import { IMcpResourceScannerService, McpResourceTarget } from './mcpResourceScannerService.js';2627export interface ILocalMcpServerInfo {28name: string;29version?: string;30id?: string;31displayName?: string;32galleryUrl?: string;33description?: string;34repositoryUrl?: string;35publisher?: string;36publisherDisplayName?: string;37icon?: {38dark: string;39light: string;40};41codicon?: string;42manifest?: IGalleryMcpServerConfiguration;43readmeUrl?: URI;44location?: URI;45licenseUrl?: string;46}4748export abstract class AbstractCommonMcpManagementService extends Disposable {4950_serviceBrand: undefined;5152getMcpServerConfigurationFromManifest(manifest: IGalleryMcpServerConfiguration, packageType: RegistryType): Omit<IInstallableMcpServer, 'name'> {53let config: IMcpServerConfiguration;54const inputs: IMcpServerVariable[] = [];5556if (packageType === RegistryType.REMOTE && manifest.remotes?.length) {57const headers: Record<string, string> = {};58for (const input of manifest.remotes[0].headers ?? []) {59const variables = input.variables ? this.getVariables(input.variables) : [];60let value = input.value;61for (const variable of variables) {62value = value.replace(`{${variable.id}}`, `\${input:${variable.id}}`);63}64headers[input.name] = value;65if (variables.length) {66inputs.push(...variables);67}68}69config = {70type: McpServerType.REMOTE,71url: manifest.remotes[0].url,72headers: Object.keys(headers).length ? headers : undefined,73};74} else {75const serverPackage = manifest.packages?.find(p => p.registry_type === packageType) ?? manifest.packages?.[0];76if (!serverPackage) {77throw new Error(`No server package found`);78}7980const args: string[] = [];81const env: Record<string, string> = {};8283if (serverPackage.registry_type === RegistryType.DOCKER) {84args.push('run');85args.push('-i');86args.push('--rm');87}8889for (const arg of serverPackage.runtime_arguments ?? []) {90const variables = arg.variables ? this.getVariables(arg.variables) : [];91if (arg.type === 'positional') {92let value = arg.value;93if (value) {94for (const variable of variables) {95value = value.replace(`{${variable.id}}`, `\${input:${variable.id}}`);96}97}98args.push(value ?? arg.value_hint);99} else if (arg.type === 'named') {100args.push(arg.name);101if (arg.value) {102let value = arg.value;103for (const variable of variables) {104value = value.replace(`{${variable.id}}`, `\${input:${variable.id}}`);105}106args.push(value);107}108}109if (variables.length) {110inputs.push(...variables);111}112}113114for (const input of serverPackage.environment_variables ?? []) {115const variables = input.variables ? this.getVariables(input.variables) : [];116let value = input.value;117for (const variable of variables) {118value = value.replace(`{${variable.id}}`, `\${input:${variable.id}}`);119}120env[input.name] = value;121if (variables.length) {122inputs.push(...variables);123}124if (serverPackage.registry_type === RegistryType.DOCKER) {125args.push('-e');126args.push(input.name);127}128}129130if (serverPackage.registry_type === RegistryType.NODE) {131args.push(serverPackage.version ? `${serverPackage.identifier}@${serverPackage.version}` : serverPackage.identifier);132}133else if (serverPackage.registry_type === RegistryType.PYTHON) {134args.push(serverPackage.version ? `${serverPackage.identifier}==${serverPackage.version}` : serverPackage.identifier);135}136else if (serverPackage.registry_type === RegistryType.DOCKER) {137args.push(serverPackage.version ? `${serverPackage.identifier}:${serverPackage.version}` : serverPackage.identifier);138}139else if (serverPackage.registry_type === RegistryType.NUGET) {140args.push(serverPackage.version ? `${serverPackage.identifier}@${serverPackage.version}` : serverPackage.identifier);141args.push('--yes'); // installation is confirmed by the UI, so --yes is appropriate here142if (serverPackage.package_arguments?.length) {143args.push('--');144}145}146147for (const arg of serverPackage.package_arguments ?? []) {148const variables = arg.variables ? this.getVariables(arg.variables) : [];149if (arg.type === 'positional') {150let value = arg.value;151if (value) {152for (const variable of variables) {153value = value.replace(`{${variable.id}}`, `\${input:${variable.id}}`);154}155}156args.push(value ?? arg.value_hint);157} else if (arg.type === 'named') {158args.push(arg.name);159if (arg.value) {160let value = arg.value;161for (const variable of variables) {162value = value.replace(`{${variable.id}}`, `\${input:${variable.id}}`);163}164args.push(value);165}166}167if (variables.length) {168inputs.push(...variables);169}170}171172config = {173type: McpServerType.LOCAL,174command: this.getCommandName(serverPackage.registry_type),175args: args.length ? args : undefined,176env: Object.keys(env).length ? env : undefined,177};178}179180return {181config,182inputs: inputs.length ? inputs : undefined,183};184}185186protected getCommandName(packageType: RegistryType): string {187switch (packageType) {188case RegistryType.NODE: return 'npx';189case RegistryType.DOCKER: return 'docker';190case RegistryType.PYTHON: return 'uvx';191case RegistryType.NUGET: return 'dnx';192}193return packageType;194}195196protected getVariables(variableInputs: Record<string, IMcpServerInput>): IMcpServerVariable[] {197const variables: IMcpServerVariable[] = [];198for (const [key, value] of Object.entries(variableInputs)) {199variables.push({200id: key,201type: value.choices ? McpServerVariableType.PICK : McpServerVariableType.PROMPT,202description: value.description ?? '',203password: !!value.is_secret,204default: value.default,205options: value.choices,206});207}208return variables;209}210211}212213export abstract class AbstractMcpResourceManagementService extends AbstractCommonMcpManagementService {214215private initializePromise: Promise<void> | undefined;216private readonly reloadConfigurationScheduler: RunOnceScheduler;217private local = new Map<string, ILocalMcpServer>();218219protected readonly _onInstallMcpServer = this._register(new Emitter<InstallMcpServerEvent>());220readonly onInstallMcpServer = this._onInstallMcpServer.event;221222protected readonly _onDidInstallMcpServers = this._register(new Emitter<InstallMcpServerResult[]>());223get onDidInstallMcpServers() { return this._onDidInstallMcpServers.event; }224225protected readonly _onDidUpdateMcpServers = this._register(new Emitter<InstallMcpServerResult[]>());226get onDidUpdateMcpServers() { return this._onDidUpdateMcpServers.event; }227228protected readonly _onUninstallMcpServer = this._register(new Emitter<UninstallMcpServerEvent>());229get onUninstallMcpServer() { return this._onUninstallMcpServer.event; }230231protected _onDidUninstallMcpServer = this._register(new Emitter<DidUninstallMcpServerEvent>());232get onDidUninstallMcpServer() { return this._onDidUninstallMcpServer.event; }233234constructor(235protected readonly mcpResource: URI,236protected readonly target: McpResourceTarget,237@IMcpGalleryService protected readonly mcpGalleryService: IMcpGalleryService,238@IFileService protected readonly fileService: IFileService,239@IUriIdentityService protected readonly uriIdentityService: IUriIdentityService,240@ILogService protected readonly logService: ILogService,241@IMcpResourceScannerService protected readonly mcpResourceScannerService: IMcpResourceScannerService,242) {243super();244this.reloadConfigurationScheduler = this._register(new RunOnceScheduler(() => this.updateLocal(), 50));245}246247private initialize(): Promise<void> {248if (!this.initializePromise) {249this.initializePromise = (async () => {250try {251this.local = await this.populateLocalServers();252} finally {253this.startWatching();254}255})();256}257return this.initializePromise;258}259260private async populateLocalServers(): Promise<Map<string, ILocalMcpServer>> {261this.logService.trace('AbstractMcpResourceManagementService#populateLocalServers', this.mcpResource.toString());262const local = new Map<string, ILocalMcpServer>();263try {264const scannedMcpServers = await this.mcpResourceScannerService.scanMcpServers(this.mcpResource, this.target);265if (scannedMcpServers.servers) {266await Promise.allSettled(Object.entries(scannedMcpServers.servers).map(async ([name, scannedServer]) => {267const server = await this.scanLocalServer(name, scannedServer);268local.set(name, server);269}));270}271} catch (error) {272this.logService.debug('Could not read user MCP servers:', error);273throw error;274}275return local;276}277278private startWatching(): void {279this._register(this.fileService.watch(this.mcpResource));280this._register(this.fileService.onDidFilesChange(e => {281if (e.affects(this.mcpResource)) {282this.reloadConfigurationScheduler.schedule();283}284}));285}286287protected async updateLocal(): Promise<void> {288try {289const current = await this.populateLocalServers();290291const added: ILocalMcpServer[] = [];292const updated: ILocalMcpServer[] = [];293const removed = [...this.local.keys()].filter(name => !current.has(name));294295for (const server of removed) {296this.local.delete(server);297}298299for (const [name, server] of current) {300const previous = this.local.get(name);301if (previous) {302if (!equals(previous, server)) {303updated.push(server);304this.local.set(name, server);305}306} else {307added.push(server);308this.local.set(name, server);309}310}311312for (const server of removed) {313this.local.delete(server);314this._onDidUninstallMcpServer.fire({ name: server, mcpResource: this.mcpResource });315}316317if (updated.length) {318this._onDidUpdateMcpServers.fire(updated.map(server => ({ name: server.name, local: server, mcpResource: this.mcpResource })));319}320321if (added.length) {322this._onDidInstallMcpServers.fire(added.map(server => ({ name: server.name, local: server, mcpResource: this.mcpResource })));323}324325} catch (error) {326this.logService.error('Failed to load installed MCP servers:', error);327}328}329330async getInstalled(): Promise<ILocalMcpServer[]> {331await this.initialize();332return Array.from(this.local.values());333}334335protected async scanLocalServer(name: string, config: IMcpServerConfiguration): Promise<ILocalMcpServer> {336let mcpServerInfo = await this.getLocalServerInfo(name, config);337if (!mcpServerInfo) {338mcpServerInfo = { name, version: config.version, galleryUrl: isString(config.gallery) ? config.gallery : undefined };339}340341return {342name,343config,344mcpResource: this.mcpResource,345version: mcpServerInfo.version,346location: mcpServerInfo.location,347displayName: mcpServerInfo.displayName,348description: mcpServerInfo.description,349publisher: mcpServerInfo.publisher,350publisherDisplayName: mcpServerInfo.publisherDisplayName,351galleryUrl: mcpServerInfo.galleryUrl,352repositoryUrl: mcpServerInfo.repositoryUrl,353readmeUrl: mcpServerInfo.readmeUrl,354icon: mcpServerInfo.icon,355codicon: mcpServerInfo.codicon,356manifest: mcpServerInfo.manifest,357source: config.gallery ? 'gallery' : 'local'358};359}360361async install(server: IInstallableMcpServer, options?: Omit<InstallOptions, 'mcpResource'>): Promise<ILocalMcpServer> {362this.logService.trace('MCP Management Service: install', server.name);363364this._onInstallMcpServer.fire({ name: server.name, mcpResource: this.mcpResource });365try {366await this.mcpResourceScannerService.addMcpServers([server], this.mcpResource, this.target);367await this.updateLocal();368const local = this.local.get(server.name);369if (!local) {370throw new Error(`Failed to install MCP server: ${server.name}`);371}372return local;373} catch (e) {374this._onDidInstallMcpServers.fire([{ name: server.name, error: e, mcpResource: this.mcpResource }]);375throw e;376}377}378379async uninstall(server: ILocalMcpServer, options?: Omit<UninstallOptions, 'mcpResource'>): Promise<void> {380this.logService.trace('MCP Management Service: uninstall', server.name);381this._onUninstallMcpServer.fire({ name: server.name, mcpResource: this.mcpResource });382383try {384const currentServers = await this.mcpResourceScannerService.scanMcpServers(this.mcpResource, this.target);385if (!currentServers.servers) {386return;387}388await this.mcpResourceScannerService.removeMcpServers([server.name], this.mcpResource, this.target);389if (server.location) {390await this.fileService.del(URI.revive(server.location), { recursive: true });391}392await this.updateLocal();393} catch (e) {394this._onDidUninstallMcpServer.fire({ name: server.name, error: e, mcpResource: this.mcpResource });395throw e;396}397}398399abstract installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise<ILocalMcpServer>;400abstract updateMetadata(local: ILocalMcpServer, server: IGalleryMcpServer, profileLocation: URI): Promise<ILocalMcpServer>;401protected abstract getLocalServerInfo(name: string, mcpServerConfig: IMcpServerConfiguration): Promise<ILocalMcpServerInfo | undefined>;402protected abstract installFromUri(uri: URI, options?: Omit<InstallOptions, 'mcpResource'>): Promise<ILocalMcpServer>;403}404405export class McpUserResourceManagementService extends AbstractMcpResourceManagementService {406407protected readonly mcpLocation: URI;408409constructor(410mcpResource: URI,411@IMcpGalleryService mcpGalleryService: IMcpGalleryService,412@IFileService fileService: IFileService,413@IUriIdentityService uriIdentityService: IUriIdentityService,414@ILogService logService: ILogService,415@IMcpResourceScannerService mcpResourceScannerService: IMcpResourceScannerService,416@IEnvironmentService environmentService: IEnvironmentService417) {418super(mcpResource, ConfigurationTarget.USER, mcpGalleryService, fileService, uriIdentityService, logService, mcpResourceScannerService);419this.mcpLocation = uriIdentityService.extUri.joinPath(environmentService.userRoamingDataHome, 'mcp');420}421422async installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise<ILocalMcpServer> {423throw new Error('Not supported');424}425426async updateMetadata(local: ILocalMcpServer, gallery: IGalleryMcpServer): Promise<ILocalMcpServer> {427await this.updateMetadataFromGallery(gallery);428await this.updateLocal();429const updatedLocal = (await this.getInstalled()).find(s => s.name === local.name);430if (!updatedLocal) {431throw new Error(`Failed to find MCP server: ${local.name}`);432}433return updatedLocal;434}435436protected async updateMetadataFromGallery(gallery: IGalleryMcpServer): Promise<IGalleryMcpServerConfiguration> {437const manifest = await this.mcpGalleryService.getMcpServerConfiguration(gallery, CancellationToken.None);438const location = this.getLocation(gallery.name, gallery.version);439const manifestPath = this.uriIdentityService.extUri.joinPath(location, 'manifest.json');440const local: ILocalMcpServerInfo = {441id: gallery.id,442galleryUrl: gallery.url,443name: gallery.name,444displayName: gallery.displayName,445description: gallery.description,446version: gallery.version,447publisher: gallery.publisher,448publisherDisplayName: gallery.publisherDisplayName,449repositoryUrl: gallery.repositoryUrl,450licenseUrl: gallery.license,451icon: gallery.icon,452codicon: gallery.codicon,453manifest,454};455await this.fileService.writeFile(manifestPath, VSBuffer.fromString(JSON.stringify(local)));456457if (gallery.readmeUrl || gallery.readme) {458const readme = gallery.readme ? gallery.readme : await this.mcpGalleryService.getReadme(gallery, CancellationToken.None);459await this.fileService.writeFile(this.uriIdentityService.extUri.joinPath(location, 'README.md'), VSBuffer.fromString(readme));460}461462return manifest;463}464465protected async getLocalServerInfo(name: string, mcpServerConfig: IMcpServerConfiguration): Promise<ILocalMcpServerInfo | undefined> {466let storedMcpServerInfo: ILocalMcpServerInfo | undefined;467let location: URI | undefined;468let readmeUrl: URI | undefined;469if (mcpServerConfig.gallery) {470location = this.getLocation(name, mcpServerConfig.version);471const manifestLocation = this.uriIdentityService.extUri.joinPath(location, 'manifest.json');472try {473const content = await this.fileService.readFile(manifestLocation);474storedMcpServerInfo = JSON.parse(content.value.toString()) as ILocalMcpServerInfo;475storedMcpServerInfo.location = location;476readmeUrl = this.uriIdentityService.extUri.joinPath(location, 'README.md');477if (!await this.fileService.exists(readmeUrl)) {478readmeUrl = undefined;479}480storedMcpServerInfo.readmeUrl = readmeUrl;481} catch (e) {482this.logService.error('MCP Management Service: failed to read manifest', location.toString(), e);483}484}485return storedMcpServerInfo;486}487488protected getLocation(name: string, version?: string): URI {489name = name.replace('/', '.');490return this.uriIdentityService.extUri.joinPath(this.mcpLocation, version ? `${name}-${version}` : name);491}492493protected override installFromUri(uri: URI, options?: Omit<InstallOptions, 'mcpResource'>): Promise<ILocalMcpServer> {494throw new Error('Method not supported.');495}496497}498499export abstract class AbstractMcpManagementService extends AbstractCommonMcpManagementService implements IMcpManagementService {500501constructor(502@IAllowedMcpServersService protected readonly allowedMcpServersService: IAllowedMcpServersService,503) {504super();505}506507canInstall(server: IGalleryMcpServer | IInstallableMcpServer): true | IMarkdownString {508const allowedToInstall = this.allowedMcpServersService.isAllowed(server);509if (allowedToInstall !== true) {510return new MarkdownString(localize('not allowed to install', "This mcp server cannot be installed because {0}", allowedToInstall.value));511}512return true;513}514515abstract onInstallMcpServer: Event<InstallMcpServerEvent>;516abstract onDidInstallMcpServers: Event<readonly InstallMcpServerResult[]>;517abstract onDidUpdateMcpServers: Event<readonly InstallMcpServerResult[]>;518abstract onUninstallMcpServer: Event<UninstallMcpServerEvent>;519abstract onDidUninstallMcpServer: Event<DidUninstallMcpServerEvent>;520521abstract getInstalled(mcpResource?: URI): Promise<ILocalMcpServer[]>;522abstract install(server: IInstallableMcpServer, options?: InstallOptions): Promise<ILocalMcpServer>;523abstract installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise<ILocalMcpServer>;524abstract updateMetadata(local: ILocalMcpServer, server: IGalleryMcpServer, profileLocation?: URI): Promise<ILocalMcpServer>;525abstract uninstall(server: ILocalMcpServer, options?: UninstallOptions): Promise<void>;526}527528export class McpManagementService extends AbstractMcpManagementService implements IMcpManagementService {529530private readonly _onInstallMcpServer = this._register(new Emitter<InstallMcpServerEvent>());531readonly onInstallMcpServer = this._onInstallMcpServer.event;532533private readonly _onDidInstallMcpServers = this._register(new Emitter<readonly InstallMcpServerResult[]>());534readonly onDidInstallMcpServers = this._onDidInstallMcpServers.event;535536private readonly _onDidUpdateMcpServers = this._register(new Emitter<readonly InstallMcpServerResult[]>());537readonly onDidUpdateMcpServers = this._onDidUpdateMcpServers.event;538539private readonly _onUninstallMcpServer = this._register(new Emitter<UninstallMcpServerEvent>());540readonly onUninstallMcpServer = this._onUninstallMcpServer.event;541542private readonly _onDidUninstallMcpServer = this._register(new Emitter<DidUninstallMcpServerEvent>());543readonly onDidUninstallMcpServer = this._onDidUninstallMcpServer.event;544545private readonly mcpResourceManagementServices = new ResourceMap<{ service: McpUserResourceManagementService } & IDisposable>();546547constructor(548@IAllowedMcpServersService allowedMcpServersService: IAllowedMcpServersService,549@IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService,550@IInstantiationService protected readonly instantiationService: IInstantiationService,551) {552super(allowedMcpServersService);553}554555private getMcpResourceManagementService(mcpResource: URI): McpUserResourceManagementService {556let mcpResourceManagementService = this.mcpResourceManagementServices.get(mcpResource);557if (!mcpResourceManagementService) {558const disposables = new DisposableStore();559const service = disposables.add(this.createMcpResourceManagementService(mcpResource));560disposables.add(service.onInstallMcpServer(e => this._onInstallMcpServer.fire(e)));561disposables.add(service.onDidInstallMcpServers(e => this._onDidInstallMcpServers.fire(e)));562disposables.add(service.onDidUpdateMcpServers(e => this._onDidUpdateMcpServers.fire(e)));563disposables.add(service.onUninstallMcpServer(e => this._onUninstallMcpServer.fire(e)));564disposables.add(service.onDidUninstallMcpServer(e => this._onDidUninstallMcpServer.fire(e)));565this.mcpResourceManagementServices.set(mcpResource, mcpResourceManagementService = { service, dispose: () => disposables.dispose() });566}567return mcpResourceManagementService.service;568}569570async getInstalled(mcpResource?: URI): Promise<ILocalMcpServer[]> {571const mcpResourceUri = mcpResource || this.userDataProfilesService.defaultProfile.mcpResource;572return this.getMcpResourceManagementService(mcpResourceUri).getInstalled();573}574575async install(server: IInstallableMcpServer, options?: InstallOptions): Promise<ILocalMcpServer> {576const mcpResourceUri = options?.mcpResource || this.userDataProfilesService.defaultProfile.mcpResource;577return this.getMcpResourceManagementService(mcpResourceUri).install(server, options);578}579580async uninstall(server: ILocalMcpServer, options?: UninstallOptions): Promise<void> {581const mcpResourceUri = options?.mcpResource || this.userDataProfilesService.defaultProfile.mcpResource;582return this.getMcpResourceManagementService(mcpResourceUri).uninstall(server, options);583}584585async installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise<ILocalMcpServer> {586const mcpResourceUri = options?.mcpResource || this.userDataProfilesService.defaultProfile.mcpResource;587return this.getMcpResourceManagementService(mcpResourceUri).installFromGallery(server, options);588}589590async updateMetadata(local: ILocalMcpServer, gallery: IGalleryMcpServer, mcpResource?: URI): Promise<ILocalMcpServer> {591return this.getMcpResourceManagementService(mcpResource || this.userDataProfilesService.defaultProfile.mcpResource).updateMetadata(local, gallery);592}593594override dispose(): void {595this.mcpResourceManagementServices.forEach(service => service.dispose());596this.mcpResourceManagementServices.clear();597super.dispose();598}599600protected createMcpResourceManagementService(mcpResource: URI): McpUserResourceManagementService {601return this.instantiationService.createInstance(McpUserResourceManagementService, mcpResource);602}603604}605606607