Path: blob/main/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.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 { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js';6import { observableValue } from '../../../../../base/common/observable.js';7import { isFalsyOrWhitespace } from '../../../../../base/common/strings.js';8import { localize } from '../../../../../nls.js';9import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js';10import { IMcpCollectionContribution } from '../../../../../platform/extensions/common/extensions.js';11import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';12import { IExtensionService } from '../../../../services/extensions/common/extensions.js';13import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js';14import { mcpActivationEvent, mcpContributionPoint } from '../mcpConfiguration.js';15import { IMcpRegistry } from '../mcpRegistryTypes.js';16import { extensionPrefixedIdentifier, McpServerDefinition, McpServerTrust } from '../mcpTypes.js';17import { IMcpDiscovery } from './mcpDiscovery.js';1819const cacheKey = 'mcp.extCachedServers';2021interface IServerCacheEntry {22readonly servers: readonly McpServerDefinition.Serialized[];23}2425const _mcpExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint(mcpContributionPoint);2627const enum PersistWhen {28CollectionExists,29Always,30}3132export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery {3334readonly fromGallery = false;3536private readonly _extensionCollectionIdsToPersist = new Map<string, PersistWhen>();37private readonly cachedServers: { [collcetionId: string]: IServerCacheEntry };3839constructor(40@IMcpRegistry private readonly _mcpRegistry: IMcpRegistry,41@IStorageService storageService: IStorageService,42@IExtensionService private readonly _extensionService: IExtensionService,43) {44super();45this.cachedServers = storageService.getObject(cacheKey, StorageScope.WORKSPACE, {});4647this._register(storageService.onWillSaveState(() => {48let updated = false;49for (const [collectionId, behavior] of this._extensionCollectionIdsToPersist.entries()) {50const collection = this._mcpRegistry.collections.get().find(c => c.id === collectionId);51let defs = collection?.serverDefinitions.get();52if (!collection || collection.lazy) {53if (behavior === PersistWhen.Always) {54defs = [];55} else {56continue;57}58}5960if (defs) {61updated = true;62this.cachedServers[collectionId] = { servers: defs.map(McpServerDefinition.toSerialized) };63}64}6566if (updated) {67storageService.store(cacheKey, this.cachedServers, StorageScope.WORKSPACE, StorageTarget.MACHINE);68}69}));70}7172public start(): void {73const extensionCollections = this._register(new DisposableMap<string>());74this._register(_mcpExtensionPoint.setHandler((_extensions, delta) => {75const { added, removed } = delta;7677for (const collections of removed) {78for (const coll of collections.value) {79extensionCollections.deleteAndDispose(extensionPrefixedIdentifier(collections.description.identifier, coll.id));80}81}8283for (const collections of added) {8485if (!ExtensionMcpDiscovery._validate(collections)) {86continue;87}8889for (const coll of collections.value) {90const id = extensionPrefixedIdentifier(collections.description.identifier, coll.id);91this._extensionCollectionIdsToPersist.set(id, PersistWhen.CollectionExists);9293const serverDefs = this.cachedServers.hasOwnProperty(id) ? this.cachedServers[id].servers : undefined;94const dispo = this._mcpRegistry.registerCollection({95id,96label: coll.label,97remoteAuthority: null,98trustBehavior: McpServerTrust.Kind.Trusted,99scope: StorageScope.WORKSPACE,100configTarget: ConfigurationTarget.USER,101serverDefinitions: observableValue<McpServerDefinition[]>(this, serverDefs?.map(McpServerDefinition.fromSerialized) || []),102lazy: {103isCached: !!serverDefs,104load: () => this._activateExtensionServers(coll.id).then(() => {105// persist (an empty collection) in case the extension doesn't end up publishing one106this._extensionCollectionIdsToPersist.set(id, PersistWhen.Always);107}),108removed: () => extensionCollections.deleteAndDispose(id),109},110source: collections.description.identifier111});112113extensionCollections.set(id, dispo);114}115}116}));117}118119private async _activateExtensionServers(collectionId: string): Promise<void> {120await this._extensionService.activateByEvent(mcpActivationEvent(collectionId));121await Promise.all(this._mcpRegistry.delegates.get()122.map(r => r.waitForInitialProviderPromises()));123}124125private static _validate(user: extensionsRegistry.IExtensionPointUser<IMcpCollectionContribution[]>): boolean {126127if (!Array.isArray(user.value)) {128user.collector.error(localize('invalidData', "Expected an array of MCP collections"));129return false;130}131132for (const contribution of user.value) {133if (typeof contribution.id !== 'string' || isFalsyOrWhitespace(contribution.id)) {134user.collector.error(localize('invalidId', "Expected 'id' to be a non-empty string."));135return false;136}137if (typeof contribution.label !== 'string' || isFalsyOrWhitespace(contribution.label)) {138user.collector.error(localize('invalidLabel', "Expected 'label' to be a non-empty string."));139return false;140}141}142143return true;144}145}146147148