Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts
5319 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 { assertNever } from '../../../../base/common/assert.js';6import { Codicon } from '../../../../base/common/codicons.js';7import { Emitter } from '../../../../base/common/event.js';8import { MarkdownString } from '../../../../base/common/htmlContent.js';9import { Iterable } from '../../../../base/common/iterator.js';10import { Lazy } from '../../../../base/common/lazy.js';11import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';12import { derived, IObservable, observableValue, autorunSelfDisposable } from '../../../../base/common/observable.js';13import { isDefined } from '../../../../base/common/types.js';14import { URI } from '../../../../base/common/uri.js';15import { localize } from '../../../../nls.js';16import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';17import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';18import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';19import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';20import { ILabelService } from '../../../../platform/label/common/label.js';21import { ILogService } from '../../../../platform/log/common/log.js';22import { mcpAccessConfig, McpAccessValue } from '../../../../platform/mcp/common/mcpManagement.js';23import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';24import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';25import { IQuickInputButton, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';26import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';27import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js';28import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js';29import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js';30import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js';31import { IMcpDevModeDebugging } from './mcpDevMode.js';32import { McpRegistryInputStorage } from './mcpRegistryInputStorage.js';33import { IMcpHostDelegate, IMcpRegistry, IMcpResolveConnectionOptions } from './mcpRegistryTypes.js';34import { McpServerConnection } from './mcpServerConnection.js';35import { IMcpServerConnection, LazyCollectionState, McpCollectionDefinition, McpDefinitionReference, McpServerDefinition, McpServerLaunch, McpServerTrust, McpStartServerInteraction, UserInteractionRequiredError } from './mcpTypes.js';3637const notTrustedNonce = '__vscode_not_trusted';3839export class McpRegistry extends Disposable implements IMcpRegistry {40declare public readonly _serviceBrand: undefined;4142private readonly _collections = observableValue<readonly McpCollectionDefinition[]>('collections', []);43private readonly _delegates = observableValue<readonly IMcpHostDelegate[]>('delegates', []);44private readonly _mcpAccessValue: IObservable<string>;45public readonly collections: IObservable<readonly McpCollectionDefinition[]> = derived(reader => {46if (this._mcpAccessValue.read(reader) === McpAccessValue.None) {47return [];48}49return this._collections.read(reader);50});5152private readonly _workspaceStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.WORKSPACE, StorageTarget.USER)));53private readonly _profileStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.PROFILE, StorageTarget.USER)));5455private readonly _ongoingLazyActivations = observableValue(this, 0);5657public readonly lazyCollectionState = derived(reader => {58if (this._mcpAccessValue.read(reader) === McpAccessValue.None) {59return { state: LazyCollectionState.AllKnown, collections: [] };60}6162if (this._ongoingLazyActivations.read(reader) > 0) {63return { state: LazyCollectionState.LoadingUnknown, collections: [] };64}65const collections = this._collections.read(reader);66const hasUnknown = collections.some(c => c.lazy && c.lazy.isCached === false);67return hasUnknown ? { state: LazyCollectionState.HasUnknown, collections: collections.filter(c => c.lazy && c.lazy.isCached === false) } : { state: LazyCollectionState.AllKnown, collections: [] };68});6970public get delegates(): IObservable<readonly IMcpHostDelegate[]> {71return this._delegates;72}7374private readonly _onDidChangeInputs = this._register(new Emitter<void>());75public readonly onDidChangeInputs = this._onDidChangeInputs.event;7677constructor(78@IInstantiationService private readonly _instantiationService: IInstantiationService,79@IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService,80@IDialogService private readonly _dialogService: IDialogService,81@INotificationService private readonly _notificationService: INotificationService,82@IEditorService private readonly _editorService: IEditorService,83@IConfigurationService configurationService: IConfigurationService,84@IQuickInputService private readonly _quickInputService: IQuickInputService,85@ILabelService private readonly _labelService: ILabelService,86@ILogService private readonly _logService: ILogService,87) {88super();89this._mcpAccessValue = observableConfigValue(mcpAccessConfig, McpAccessValue.All, configurationService);90}9192public registerDelegate(delegate: IMcpHostDelegate): IDisposable {93const delegates = this._delegates.get().slice();94delegates.push(delegate);95delegates.sort((a, b) => b.priority - a.priority);96this._delegates.set(delegates, undefined);9798return {99dispose: () => {100const delegates = this._delegates.get().filter(d => d !== delegate);101this._delegates.set(delegates, undefined);102}103};104}105106public registerCollection(collection: McpCollectionDefinition): IDisposable {107const currentCollections = this._collections.get();108const toReplace = currentCollections.find(c => c.id === collection.id);109110// Incoming collections replace the "lazy" versions. See `ExtensionMcpDiscovery` for an example.111if (toReplace && !toReplace.lazy) {112return Disposable.None;113} else if (toReplace) {114this._collections.set(currentCollections.map(c => c === toReplace ? collection : c), undefined);115} else {116this._collections.set([...currentCollections, collection]117.sort((a, b) => (a.presentation?.order || 0) - (b.presentation?.order || 0)), undefined);118}119120return {121dispose: () => {122const currentCollections = this._collections.get();123this._collections.set(currentCollections.filter(c => c !== collection), undefined);124}125};126}127128public getServerDefinition(collectionRef: McpDefinitionReference, definitionRef: McpDefinitionReference): IObservable<{ server: McpServerDefinition | undefined; collection: McpCollectionDefinition | undefined }> {129const collectionObs = this._collections.map(cols => cols.find(c => c.id === collectionRef.id));130return collectionObs.map((collection, reader) => {131const server = collection?.serverDefinitions.read(reader).find(s => s.id === definitionRef.id);132return { collection, server };133});134}135136public async discoverCollections(): Promise<McpCollectionDefinition[]> {137const toDiscover = this._collections.get().filter(c => c.lazy && !c.lazy.isCached);138139this._ongoingLazyActivations.set(this._ongoingLazyActivations.get() + 1, undefined);140await Promise.all(toDiscover.map(c => c.lazy?.load())).finally(() => {141this._ongoingLazyActivations.set(this._ongoingLazyActivations.get() - 1, undefined);142});143144const found: McpCollectionDefinition[] = [];145const current = this._collections.get();146for (const collection of toDiscover) {147const rec = current.find(c => c.id === collection.id);148if (!rec) {149// ignored150} else if (rec.lazy) {151rec.lazy.removed?.(); // did not get replaced by the non-lazy version152} else {153found.push(rec);154}155}156157158return found;159}160161private _getInputStorage(scope: StorageScope): McpRegistryInputStorage {162return scope === StorageScope.WORKSPACE ? this._workspaceStorage.value : this._profileStorage.value;163}164165private _getInputStorageInConfigTarget(configTarget: ConfigurationTarget): McpRegistryInputStorage {166return this._getInputStorage(167configTarget === ConfigurationTarget.WORKSPACE || configTarget === ConfigurationTarget.WORKSPACE_FOLDER168? StorageScope.WORKSPACE169: StorageScope.PROFILE170);171}172173public async clearSavedInputs(scope: StorageScope, inputId?: string) {174const storage = this._getInputStorage(scope);175if (inputId) {176await storage.clear(inputId);177} else {178storage.clearAll();179}180181this._onDidChangeInputs.fire();182}183184public async editSavedInput(inputId: string, folderData: IWorkspaceFolderData | undefined, configSection: string, target: ConfigurationTarget): Promise<void> {185const storage = this._getInputStorageInConfigTarget(target);186const expr = ConfigurationResolverExpression.parse(inputId);187188const stored = await storage.getMap();189const previous = stored[inputId].value;190await this._configurationResolverService.resolveWithInteraction(folderData, expr, configSection, previous ? { [inputId.slice(2, -1)]: previous } : {}, target);191await this._updateStorageWithExpressionInputs(storage, expr);192}193194public async setSavedInput(inputId: string, target: ConfigurationTarget, value: string): Promise<void> {195const storage = this._getInputStorageInConfigTarget(target);196const expr = ConfigurationResolverExpression.parse(inputId);197for (const unresolved of expr.unresolved()) {198expr.resolve(unresolved, value);199break;200}201await this._updateStorageWithExpressionInputs(storage, expr);202}203204public getSavedInputs(scope: StorageScope): Promise<{ [id: string]: IResolvedValue }> {205return this._getInputStorage(scope).getMap();206}207208private async _checkTrust(collection: McpCollectionDefinition, definition: McpServerDefinition, {209trustNonceBearer,210interaction,211promptType = 'only-new',212autoTrustChanges = false,213errorOnUserInteraction = false,214}: IMcpResolveConnectionOptions) {215if (collection.trustBehavior === McpServerTrust.Kind.Trusted) {216this._logService.trace(`MCP server ${definition.id} is trusted, no trust prompt needed`);217return true;218} else if (collection.trustBehavior === McpServerTrust.Kind.TrustedOnNonce) {219if (definition.cacheNonce === trustNonceBearer.trustedAtNonce) {220this._logService.trace(`MCP server ${definition.id} is unchanged, no trust prompt needed`);221return true;222}223224if (autoTrustChanges) {225this._logService.trace(`MCP server ${definition.id} is was changed but user explicitly executed`);226trustNonceBearer.trustedAtNonce = definition.cacheNonce;227return true;228}229230if (trustNonceBearer.trustedAtNonce === notTrustedNonce) {231if (promptType === 'all-untrusted') {232if (errorOnUserInteraction) {233throw new UserInteractionRequiredError('serverTrust');234}235return this._promptForTrust(definition, collection, interaction, trustNonceBearer);236} else {237this._logService.trace(`MCP server ${definition.id} is untrusted, denying trust prompt`);238return false;239}240}241242if (promptType === 'never') {243this._logService.trace(`MCP server ${definition.id} trust state is unknown, skipping prompt`);244return false;245}246247if (errorOnUserInteraction) {248throw new UserInteractionRequiredError('serverTrust');249}250251const didTrust = await this._promptForTrust(definition, collection, interaction, trustNonceBearer);252if (didTrust) {253return true;254}255if (didTrust === undefined) {256return undefined;257}258259trustNonceBearer.trustedAtNonce = notTrustedNonce;260return false;261} else {262assertNever(collection.trustBehavior);263}264}265266private async _promptForTrust(definition: McpServerDefinition, collection: McpCollectionDefinition, interaction: McpStartServerInteraction | undefined, trustNonceBearer: { trustedAtNonce: string | undefined }): Promise<boolean> {267interaction ??= new McpStartServerInteraction();268interaction.participants.set(definition.id, { s: 'waiting', definition, collection });269270const trustedDefinitionIds = await new Promise<string[] | undefined>(resolve => {271autorunSelfDisposable(reader => {272const map = interaction.participants.observable.read(reader);273if (Iterable.some(map.values(), p => p.s === 'unknown')) {274return; // wait to gather all calls275}276277reader.dispose();278interaction.choice ??= this._promptForTrustOpenDialog(279[...map.values()].map((v) => v.s === 'waiting' ? v : undefined).filter(isDefined),280);281resolve(interaction.choice);282});283});284285this._logService.trace(`MCP trusted servers:`, trustedDefinitionIds);286287if (trustedDefinitionIds) {288trustNonceBearer.trustedAtNonce = trustedDefinitionIds.includes(definition.id)289? definition.cacheNonce290: notTrustedNonce;291}292293return !!trustedDefinitionIds?.includes(definition.id);294}295296/**297* Confirms with the user which of the provided definitions should be trusted.298* Returns undefined if the user cancelled the flow, or the list of trusted299* definition IDs otherwise.300*/301protected async _promptForTrustOpenDialog(definitions: { definition: McpServerDefinition; collection: McpCollectionDefinition }[]): Promise<string[] | undefined> {302function labelFor(r: { definition: McpServerDefinition; collection: McpCollectionDefinition }) {303const originURI = r.definition.presentation?.origin?.uri || r.collection.presentation?.origin;304let labelWithOrigin = originURI ? `[\`${r.definition.label}\`](${originURI})` : '`' + r.definition.label + '`';305306if (r.collection.source instanceof ExtensionIdentifier) {307labelWithOrigin += ` (${localize('trustFromExt', 'from {0}', r.collection.source.value)})`;308}309310return labelWithOrigin;311}312313if (definitions.length === 1) {314const def = definitions[0];315const originURI = def.definition.presentation?.origin?.uri;316317const { result } = await this._dialogService.prompt(318{319message: localize('trustTitleWithOrigin', 'Trust and run MCP server {0}?', def.definition.label),320custom: {321icon: Codicon.shield,322markdownDetails: [{323markdown: new MarkdownString(localize('mcp.trust.details', 'The MCP server {0} was updated. MCP servers may add context to your chat session and lead to unexpected behavior. Do you want to trust and run this server?', labelFor(def))),324actionHandler: () => {325const editor = this._editorService.openEditor({ resource: originURI! }, AUX_WINDOW_GROUP);326return editor.then(Boolean);327},328}]329},330buttons: [331{ label: localize('mcp.trust.yes', 'Trust'), run: () => true },332{ label: localize('mcp.trust.no', 'Do not trust'), run: () => false }333],334},335);336337return result === undefined ? undefined : (result ? [def.definition.id] : []);338}339340const list = definitions.map(d => `- ${labelFor(d)}`).join('\n');341const { result } = await this._dialogService.prompt(342{343message: localize('trustTitleWithOriginMulti', 'Trust and run {0} MCP servers?', definitions.length),344custom: {345icon: Codicon.shield,346markdownDetails: [{347markdown: new MarkdownString(localize('mcp.trust.detailsMulti', 'Several updated MCP servers were discovered:\n\n{0}\n\n MCP servers may add context to your chat session and lead to unexpected behavior. Do you want to trust and run these server?', list)),348actionHandler: (uri) => {349const editor = this._editorService.openEditor({ resource: URI.parse(uri) }, AUX_WINDOW_GROUP);350return editor.then(Boolean);351},352}]353},354buttons: [355{ label: localize('mcp.trust.yes', 'Trust'), run: () => 'all' },356{ label: localize('mcp.trust.pick', 'Pick Trusted'), run: () => 'pick' },357{ label: localize('mcp.trust.no', 'Do not trust'), run: () => 'none' },358],359},360);361362if (result === undefined) {363return undefined;364} else if (result === 'all') {365return definitions.map(d => d.definition.id);366} else if (result === 'none') {367return [];368}369370type ActionableButton = IQuickInputButton & { action: () => void };371function isActionableButton(obj: IQuickInputButton): obj is ActionableButton {372return typeof (obj as ActionableButton).action === 'function';373}374375const store = new DisposableStore();376const picker = store.add(this._quickInputService.createQuickPick<IQuickPickItem & { definitonId: string }>({ useSeparators: false }));377picker.canSelectMany = true;378picker.items = definitions.map(({ definition, collection }) => {379const buttons: ActionableButton[] = [];380if (definition.presentation?.origin) {381const origin = definition.presentation.origin;382buttons.push({383iconClass: 'codicon-go-to-file',384tooltip: 'Go to Definition',385action: () => this._editorService.openEditor({ resource: origin.uri, options: { selection: origin.range } })386});387}388389return {390type: 'item',391label: definition.label,392definitonId: definition.id,393description: collection.source instanceof ExtensionIdentifier394? collection.source.value395: (definition.presentation?.origin ? this._labelService.getUriLabel(definition.presentation.origin.uri) : undefined),396picked: false,397buttons398};399});400picker.placeholder = 'Select MCP servers to trust';401picker.ignoreFocusOut = true;402403store.add(picker.onDidTriggerItemButton(e => {404if (isActionableButton(e.button)) {405e.button.action();406}407}));408409return new Promise<string[] | undefined>(resolve => {410store.add(picker.onDidAccept(() => {411resolve(picker.selectedItems.map(item => item.definitonId));412picker.hide();413}));414store.add(picker.onDidHide(() => {415resolve(undefined);416}));417picker.show();418}).finally(() => store.dispose());419}420421private async _updateStorageWithExpressionInputs(inputStorage: McpRegistryInputStorage, expr: ConfigurationResolverExpression<unknown>): Promise<void> {422const secrets: Record<string, IResolvedValue> = {};423const inputs: Record<string, IResolvedValue> = {};424for (const [replacement, resolved] of expr.resolved()) {425if (resolved.input?.type === 'promptString' && resolved.input.password) {426secrets[replacement.id] = resolved;427} else {428inputs[replacement.id] = resolved;429}430}431432inputStorage.setPlainText(inputs);433await inputStorage.setSecrets(secrets);434this._onDidChangeInputs.fire();435}436437private async _replaceVariablesInLaunch(delegate: IMcpHostDelegate, definition: McpServerDefinition, launch: McpServerLaunch, errorOnUserInteraction?: boolean) {438if (!definition.variableReplacement) {439return launch;440}441442const { section, target, folder } = definition.variableReplacement;443const inputStorage = this._getInputStorageInConfigTarget(target);444const [previouslyStored, withRemoteFilled] = await Promise.all([445inputStorage.getMap(),446delegate.substituteVariables(definition, launch),447]);448449// pre-fill the variables we already resolved to avoid extra prompting450const expr = ConfigurationResolverExpression.parse(withRemoteFilled);451for (const replacement of expr.unresolved()) {452if (previouslyStored.hasOwnProperty(replacement.id)) {453expr.resolve(replacement, previouslyStored[replacement.id]);454}455}456457// Check if there are still unresolved variables that would require interaction458if (errorOnUserInteraction) {459const unresolved = Array.from(expr.unresolved());460if (unresolved.length > 0) {461throw new UserInteractionRequiredError('variables');462}463}464// resolve variables requiring user input465await this._configurationResolverService.resolveWithInteraction(folder, expr, section, undefined, target);466467await this._updateStorageWithExpressionInputs(inputStorage, expr);468469// resolve other non-interactive variables, returning the final object470return await this._configurationResolverService.resolveAsync(folder, expr);471}472473public async resolveConnection(opts: IMcpResolveConnectionOptions): Promise<IMcpServerConnection | undefined> {474const { collectionRef, definitionRef, interaction, logger, debug } = opts;475let collection = this._collections.get().find(c => c.id === collectionRef.id);476if (collection?.lazy) {477await collection.lazy.load();478collection = this._collections.get().find(c => c.id === collectionRef.id);479}480481const definition = collection?.serverDefinitions.get().find(s => s.id === definitionRef.id);482if (!collection || !definition) {483throw new Error(`Collection or definition not found for ${collectionRef.id} and ${definitionRef.id}`);484}485486const delegate = this._delegates.get().find(d => d.canStart(collection, definition));487if (!delegate) {488throw new Error('No delegate found that can handle the connection');489}490491const trusted = await this._checkTrust(collection, definition, opts);492interaction?.participants.set(definition.id, { s: 'resolved' });493if (!trusted) {494return undefined;495}496497let launch: McpServerLaunch | undefined = definition.launch;498if (collection.resolveServerLanch) {499launch = await collection.resolveServerLanch(definition);500if (!launch) {501return undefined; // interaction cancelled by user502}503}504505try {506launch = await this._replaceVariablesInLaunch(delegate, definition, launch, opts.errorOnUserInteraction);507508if (definition.devMode && debug) {509launch = await this._instantiationService.invokeFunction(accessor => accessor.get(IMcpDevModeDebugging).transform(definition, launch!));510}511} catch (e) {512if (e instanceof UserInteractionRequiredError) {513throw e;514}515516this._notificationService.notify({517severity: Severity.Error,518message: localize('mcp.launchError', 'Error starting {0}: {1}', definition.label, String(e)),519actions: {520primary: collection.presentation?.origin && [521{522id: 'mcp.launchError.openConfig',523class: undefined,524enabled: true,525tooltip: '',526label: localize('mcp.launchError.openConfig', 'Open Configuration'),527run: () => this._editorService.openEditor({528resource: collection.presentation!.origin,529options: { selection: definition.presentation?.origin?.range }530}),531}532]533}534});535return;536}537538return this._instantiationService.createInstance(539McpServerConnection,540collection,541definition,542delegate,543launch,544logger,545opts.errorOnUserInteraction,546opts.taskManager,547);548}549}550551552