Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpRegistry.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 { 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 } 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.lazy && c.id === collection.id);109110// Incoming collections replace the "lazy" versions. See `ExtensionMcpDiscovery` for an example.111if (toReplace) {112this._collections.set(currentCollections.map(c => c === toReplace ? collection : c), undefined);113} else {114this._collections.set([...currentCollections, collection]115.sort((a, b) => (a.presentation?.order || 0) - (b.presentation?.order || 0)), undefined);116}117118return {119dispose: () => {120const currentCollections = this._collections.get();121this._collections.set(currentCollections.filter(c => c !== collection), undefined);122}123};124}125126public getServerDefinition(collectionRef: McpDefinitionReference, definitionRef: McpDefinitionReference): IObservable<{ server: McpServerDefinition | undefined; collection: McpCollectionDefinition | undefined }> {127const collectionObs = this._collections.map(cols => cols.find(c => c.id === collectionRef.id));128return collectionObs.map((collection, reader) => {129const server = collection?.serverDefinitions.read(reader).find(s => s.id === definitionRef.id);130return { collection, server };131});132}133134public async discoverCollections(): Promise<McpCollectionDefinition[]> {135const toDiscover = this._collections.get().filter(c => c.lazy && !c.lazy.isCached);136137this._ongoingLazyActivations.set(this._ongoingLazyActivations.get() + 1, undefined);138await Promise.all(toDiscover.map(c => c.lazy?.load())).finally(() => {139this._ongoingLazyActivations.set(this._ongoingLazyActivations.get() - 1, undefined);140});141142const found: McpCollectionDefinition[] = [];143const current = this._collections.get();144for (const collection of toDiscover) {145const rec = current.find(c => c.id === collection.id);146if (!rec) {147// ignored148} else if (rec.lazy) {149rec.lazy.removed?.(); // did not get replaced by the non-lazy version150} else {151found.push(rec);152}153}154155156return found;157}158159private _getInputStorage(scope: StorageScope): McpRegistryInputStorage {160return scope === StorageScope.WORKSPACE ? this._workspaceStorage.value : this._profileStorage.value;161}162163private _getInputStorageInConfigTarget(configTarget: ConfigurationTarget): McpRegistryInputStorage {164return this._getInputStorage(165configTarget === ConfigurationTarget.WORKSPACE || configTarget === ConfigurationTarget.WORKSPACE_FOLDER166? StorageScope.WORKSPACE167: StorageScope.PROFILE168);169}170171public async clearSavedInputs(scope: StorageScope, inputId?: string) {172const storage = this._getInputStorage(scope);173if (inputId) {174await storage.clear(inputId);175} else {176storage.clearAll();177}178179this._onDidChangeInputs.fire();180}181182public async editSavedInput(inputId: string, folderData: IWorkspaceFolderData | undefined, configSection: string, target: ConfigurationTarget): Promise<void> {183const storage = this._getInputStorageInConfigTarget(target);184const expr = ConfigurationResolverExpression.parse(inputId);185186const stored = await storage.getMap();187const previous = stored[inputId].value;188await this._configurationResolverService.resolveWithInteraction(folderData, expr, configSection, previous ? { [inputId.slice(2, -1)]: previous } : {}, target);189await this._updateStorageWithExpressionInputs(storage, expr);190}191192public async setSavedInput(inputId: string, target: ConfigurationTarget, value: string): Promise<void> {193const storage = this._getInputStorageInConfigTarget(target);194const expr = ConfigurationResolverExpression.parse(inputId);195for (const unresolved of expr.unresolved()) {196expr.resolve(unresolved, value);197break;198}199await this._updateStorageWithExpressionInputs(storage, expr);200}201202public getSavedInputs(scope: StorageScope): Promise<{ [id: string]: IResolvedValue }> {203return this._getInputStorage(scope).getMap();204}205206private async _checkTrust(collection: McpCollectionDefinition, definition: McpServerDefinition, {207trustNonceBearer,208interaction,209promptType = 'only-new',210autoTrustChanges = false,211}: IMcpResolveConnectionOptions) {212if (collection.trustBehavior === McpServerTrust.Kind.Trusted) {213this._logService.trace(`MCP server ${definition.id} is trusted, no trust prompt needed`);214return true;215} else if (collection.trustBehavior === McpServerTrust.Kind.TrustedOnNonce) {216if (definition.cacheNonce === trustNonceBearer.trustedAtNonce) {217this._logService.trace(`MCP server ${definition.id} is unchanged, no trust prompt needed`);218return true;219}220221if (autoTrustChanges) {222this._logService.trace(`MCP server ${definition.id} is was changed but user explicitly executed`);223trustNonceBearer.trustedAtNonce = definition.cacheNonce;224return true;225}226227if (trustNonceBearer.trustedAtNonce === notTrustedNonce) {228if (promptType === 'all-untrusted') {229return this._promptForTrust(definition, collection, interaction, trustNonceBearer);230} else {231this._logService.trace(`MCP server ${definition.id} is untrusted, denying trust prompt`);232return false;233}234}235236if (promptType === 'never') {237this._logService.trace(`MCP server ${definition.id} trust state is unknown, skipping prompt`);238return false;239}240241const didTrust = await this._promptForTrust(definition, collection, interaction, trustNonceBearer);242if (didTrust) {243return true;244}245if (didTrust === undefined) {246return undefined;247}248249trustNonceBearer.trustedAtNonce = notTrustedNonce;250return false;251} else {252assertNever(collection.trustBehavior);253}254}255256private async _promptForTrust(definition: McpServerDefinition, collection: McpCollectionDefinition, interaction: McpStartServerInteraction | undefined, trustNonceBearer: { trustedAtNonce: string | undefined }): Promise<boolean> {257interaction ??= new McpStartServerInteraction();258interaction.participants.set(definition.id, { s: 'waiting', definition, collection });259260const trustedDefinitionIds = await new Promise<string[] | undefined>(resolve => {261autorunSelfDisposable(reader => {262const map = interaction.participants.observable.read(reader);263if (Iterable.some(map.values(), p => p.s === 'unknown')) {264return; // wait to gather all calls265}266267reader.dispose();268interaction.choice ??= this._promptForTrustOpenDialog(269[...map.values()].map((v) => v.s === 'waiting' ? v : undefined).filter(isDefined),270);271resolve(interaction.choice);272});273});274275this._logService.trace(`MCP trusted servers:`, trustedDefinitionIds);276277if (trustedDefinitionIds) {278trustNonceBearer.trustedAtNonce = trustedDefinitionIds.includes(definition.id)279? definition.cacheNonce280: notTrustedNonce;281}282283return !!trustedDefinitionIds?.includes(definition.id);284}285286/**287* Confirms with the user which of the provided definitions should be trusted.288* Returns undefined if the user cancelled the flow, or the list of trusted289* definition IDs otherwise.290*/291protected async _promptForTrustOpenDialog(definitions: { definition: McpServerDefinition; collection: McpCollectionDefinition }[]): Promise<string[] | undefined> {292function labelFor(r: { definition: McpServerDefinition; collection: McpCollectionDefinition }) {293const originURI = r.definition.presentation?.origin?.uri || r.collection.presentation?.origin;294let labelWithOrigin = originURI ? `[\`${r.definition.label}\`](${originURI})` : '`' + r.definition.label + '`';295296if (r.collection.source instanceof ExtensionIdentifier) {297labelWithOrigin += ` (${localize('trustFromExt', 'from {0}', r.collection.source.value)})`;298}299300return labelWithOrigin;301}302303if (definitions.length === 1) {304const def = definitions[0];305const originURI = def.definition.presentation?.origin?.uri;306307const { result } = await this._dialogService.prompt(308{309message: localize('trustTitleWithOrigin', 'Trust and run MCP server {0}?', def.definition.label),310custom: {311icon: Codicon.shield,312markdownDetails: [{313markdown: 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))),314actionHandler: () => {315const editor = this._editorService.openEditor({ resource: originURI! }, AUX_WINDOW_GROUP);316return editor.then(Boolean);317},318}]319},320buttons: [321{ label: localize('mcp.trust.yes', 'Trust'), run: () => true },322{ label: localize('mcp.trust.no', 'Do not trust'), run: () => false }323],324},325);326327return result === undefined ? undefined : (result ? [def.definition.id] : []);328}329330const list = definitions.map(d => `- ${labelFor(d)}`).join('\n');331const { result } = await this._dialogService.prompt(332{333message: localize('trustTitleWithOriginMulti', 'Trust and run {0} MCP servers?', definitions.length),334custom: {335icon: Codicon.shield,336markdownDetails: [{337markdown: 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)),338actionHandler: (uri) => {339const editor = this._editorService.openEditor({ resource: URI.parse(uri) }, AUX_WINDOW_GROUP);340return editor.then(Boolean);341},342}]343},344buttons: [345{ label: localize('mcp.trust.yes', 'Trust'), run: () => 'all' },346{ label: localize('mcp.trust.pick', 'Pick Trusted'), run: () => 'pick' },347{ label: localize('mcp.trust.no', 'Do not trust'), run: () => 'none' },348],349},350);351352if (result === undefined) {353return undefined;354} else if (result === 'all') {355return definitions.map(d => d.definition.id);356} else if (result === 'none') {357return [];358}359360type ActionableButton = IQuickInputButton & { action: () => void };361function isActionableButton(obj: IQuickInputButton): obj is ActionableButton {362return typeof (obj as ActionableButton).action === 'function';363}364365const store = new DisposableStore();366const picker = store.add(this._quickInputService.createQuickPick<IQuickPickItem & { definitonId: string }>({ useSeparators: false }));367picker.canSelectMany = true;368picker.items = definitions.map(({ definition, collection }) => {369const buttons: ActionableButton[] = [];370if (definition.presentation?.origin) {371const origin = definition.presentation.origin;372buttons.push({373iconClass: 'codicon-go-to-file',374tooltip: 'Go to Definition',375action: () => this._editorService.openEditor({ resource: origin.uri, options: { selection: origin.range } })376});377}378379return {380type: 'item',381label: definition.label,382definitonId: definition.id,383description: collection.source instanceof ExtensionIdentifier384? collection.source.value385: (definition.presentation?.origin ? this._labelService.getUriLabel(definition.presentation.origin.uri) : undefined),386picked: false,387buttons388};389});390picker.placeholder = 'Select MCP servers to trust';391picker.ignoreFocusOut = true;392393store.add(picker.onDidTriggerItemButton(e => {394if (isActionableButton(e.button)) {395e.button.action();396}397}));398399return new Promise<string[] | undefined>(resolve => {400picker.onDidAccept(() => {401resolve(picker.selectedItems.map(item => item.definitonId));402picker.hide();403});404picker.onDidHide(() => {405resolve(undefined);406});407picker.show();408}).finally(() => store.dispose());409}410411private async _updateStorageWithExpressionInputs(inputStorage: McpRegistryInputStorage, expr: ConfigurationResolverExpression<unknown>): Promise<void> {412const secrets: Record<string, IResolvedValue> = {};413const inputs: Record<string, IResolvedValue> = {};414for (const [replacement, resolved] of expr.resolved()) {415if (resolved.input?.type === 'promptString' && resolved.input.password) {416secrets[replacement.id] = resolved;417} else {418inputs[replacement.id] = resolved;419}420}421422inputStorage.setPlainText(inputs);423await inputStorage.setSecrets(secrets);424this._onDidChangeInputs.fire();425}426427private async _replaceVariablesInLaunch(definition: McpServerDefinition, launch: McpServerLaunch) {428if (!definition.variableReplacement) {429return launch;430}431432const { section, target, folder } = definition.variableReplacement;433const inputStorage = this._getInputStorageInConfigTarget(target);434const previouslyStored = await inputStorage.getMap();435436// pre-fill the variables we already resolved to avoid extra prompting437const expr = ConfigurationResolverExpression.parse(launch);438for (const replacement of expr.unresolved()) {439if (previouslyStored.hasOwnProperty(replacement.id)) {440expr.resolve(replacement, previouslyStored[replacement.id]);441}442}443444// resolve variables requiring user input445await this._configurationResolverService.resolveWithInteraction(folder, expr, section, undefined, target);446447await this._updateStorageWithExpressionInputs(inputStorage, expr);448449// resolve other non-interactive variables, returning the final object450return await this._configurationResolverService.resolveAsync(folder, expr);451}452453public async resolveConnection(opts: IMcpResolveConnectionOptions): Promise<IMcpServerConnection | undefined> {454const { collectionRef, definitionRef, interaction, logger, debug } = opts;455let collection = this._collections.get().find(c => c.id === collectionRef.id);456if (collection?.lazy) {457await collection.lazy.load();458collection = this._collections.get().find(c => c.id === collectionRef.id);459}460461const definition = collection?.serverDefinitions.get().find(s => s.id === definitionRef.id);462if (!collection || !definition) {463throw new Error(`Collection or definition not found for ${collectionRef.id} and ${definitionRef.id}`);464}465466const delegate = this._delegates.get().find(d => d.canStart(collection, definition));467if (!delegate) {468throw new Error('No delegate found that can handle the connection');469}470471const trusted = await this._checkTrust(collection, definition, opts);472interaction?.participants.set(definition.id, { s: 'resolved' });473if (!trusted) {474return undefined;475}476477let launch: McpServerLaunch | undefined = definition.launch;478if (collection.resolveServerLanch) {479launch = await collection.resolveServerLanch(definition);480if (!launch) {481return undefined; // interaction cancelled by user482}483}484485try {486launch = await this._replaceVariablesInLaunch(definition, launch);487488if (definition.devMode && debug) {489launch = await this._instantiationService.invokeFunction(accessor => accessor.get(IMcpDevModeDebugging).transform(definition, launch!));490}491} catch (e) {492this._notificationService.notify({493severity: Severity.Error,494message: localize('mcp.launchError', 'Error starting {0}: {1}', definition.label, String(e)),495actions: {496primary: collection.presentation?.origin && [497{498id: 'mcp.launchError.openConfig',499class: undefined,500enabled: true,501tooltip: '',502label: localize('mcp.launchError.openConfig', 'Open Configuration'),503run: () => this._editorService.openEditor({504resource: collection.presentation!.origin,505options: { selection: definition.presentation?.origin?.range }506}),507}508]509}510});511return;512}513514return this._instantiationService.createInstance(515McpServerConnection,516collection,517definition,518delegate,519launch,520logger,521);522}523}524525526