Path: blob/main/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts
5371 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 { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';11import { IMcpCollectionContribution } from '../../../../../platform/extensions/common/extensions.js';12import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';13import { IExtensionService } from '../../../../services/extensions/common/extensions.js';14import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js';15import { mcpActivationEvent, mcpContributionPoint } from '../mcpConfiguration.js';16import { IMcpRegistry } from '../mcpRegistryTypes.js';17import { extensionPrefixedIdentifier, McpServerDefinition, McpServerTrust } from '../mcpTypes.js';18import { IMcpDiscovery } from './mcpDiscovery.js';1920const cacheKey = 'mcp.extCachedServers';2122interface IServerCacheEntry {23readonly servers: readonly McpServerDefinition.Serialized[];24}2526const _mcpExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint(mcpContributionPoint);2728const enum PersistWhen {29CollectionExists,30Always,31}3233export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery {3435readonly fromGallery = false;3637private readonly _extensionCollectionIdsToPersist = new Map<string, PersistWhen>();38private readonly cachedServers: { [collcetionId: string]: IServerCacheEntry };39private readonly _conditionalCollections = this._register(new DisposableMap<string>());4041constructor(42@IMcpRegistry private readonly _mcpRegistry: IMcpRegistry,43@IStorageService storageService: IStorageService,44@IExtensionService private readonly _extensionService: IExtensionService,45@IContextKeyService private readonly _contextKeyService: IContextKeyService,46) {47super();48this.cachedServers = storageService.getObject(cacheKey, StorageScope.WORKSPACE, {});4950this._register(storageService.onWillSaveState(() => {51let updated = false;52for (const [collectionId, behavior] of this._extensionCollectionIdsToPersist.entries()) {53const collection = this._mcpRegistry.collections.get().find(c => c.id === collectionId);54let defs = collection?.serverDefinitions.get();55if (!collection || collection.lazy) {56if (behavior === PersistWhen.Always) {57defs = [];58} else {59continue;60}61}6263if (defs) {64updated = true;65this.cachedServers[collectionId] = { servers: defs.map(McpServerDefinition.toSerialized) };66}67}6869if (updated) {70storageService.store(cacheKey, this.cachedServers, StorageScope.WORKSPACE, StorageTarget.MACHINE);71}72}));73}7475public start(): void {76const extensionCollections = this._register(new DisposableMap<string>());77this._register(_mcpExtensionPoint.setHandler((_extensions, delta) => {78const { added, removed } = delta;7980for (const collections of removed) {81for (const coll of collections.value) {82const id = extensionPrefixedIdentifier(collections.description.identifier, coll.id);83extensionCollections.deleteAndDispose(id);84this._conditionalCollections.deleteAndDispose(id);85}86}8788for (const collections of added) {8990if (!ExtensionMcpDiscovery._validate(collections)) {91continue;92}9394for (const coll of collections.value) {95const id = extensionPrefixedIdentifier(collections.description.identifier, coll.id);96this._extensionCollectionIdsToPersist.set(id, PersistWhen.CollectionExists);9798// Handle conditional collections with 'when' clause99if (coll.when) {100this._registerConditionalCollection(id, coll, collections, extensionCollections);101} else {102// Register collection immediately if no 'when' clause103this._registerCollection(id, coll, collections, extensionCollections);104}105}106}107}));108}109110private _registerCollection(111id: string,112coll: IMcpCollectionContribution,113collections: extensionsRegistry.IExtensionPointUser<IMcpCollectionContribution[]>,114extensionCollections: DisposableMap<string>115) {116const serverDefs = this.cachedServers.hasOwnProperty(id) ? this.cachedServers[id].servers : undefined;117const dispo = this._mcpRegistry.registerCollection({118id,119label: coll.label,120remoteAuthority: null,121trustBehavior: McpServerTrust.Kind.Trusted,122scope: StorageScope.WORKSPACE,123configTarget: ConfigurationTarget.USER,124serverDefinitions: observableValue<McpServerDefinition[]>(this, serverDefs?.map(McpServerDefinition.fromSerialized) || []),125lazy: {126isCached: !!serverDefs,127load: () => this._activateExtensionServers(coll.id).then(() => {128// persist (an empty collection) in case the extension doesn't end up publishing one129this._extensionCollectionIdsToPersist.set(id, PersistWhen.Always);130}),131removed: () => {132extensionCollections.deleteAndDispose(id);133this._conditionalCollections.deleteAndDispose(id);134},135},136source: collections.description.identifier137});138139extensionCollections.set(id, dispo);140}141142private _registerConditionalCollection(143id: string,144coll: IMcpCollectionContribution,145collections: extensionsRegistry.IExtensionPointUser<IMcpCollectionContribution[]>,146extensionCollections: DisposableMap<string>147) {148const whenClause = ContextKeyExpr.deserialize(coll.when!);149if (!whenClause) {150// Invalid when clause, treat as always false151return;152}153154const evaluate = () => {155const nowSatisfied = this._contextKeyService.contextMatchesRules(whenClause);156const isRegistered = extensionCollections.has(id);157if (nowSatisfied && !isRegistered) {158this._registerCollection(id, coll, collections, extensionCollections);159} else if (!nowSatisfied && isRegistered) {160extensionCollections.deleteAndDispose(id);161}162};163164const contextKeyListener = this._contextKeyService.onDidChangeContext(evaluate);165evaluate();166167// Store disposable for this conditional collection168this._conditionalCollections.set(id, contextKeyListener);169}170171private async _activateExtensionServers(collectionId: string): Promise<void> {172await this._extensionService.activateByEvent(mcpActivationEvent(collectionId));173await Promise.all(this._mcpRegistry.delegates.get()174.map(r => r.waitForInitialProviderPromises()));175}176177private static _validate(user: extensionsRegistry.IExtensionPointUser<IMcpCollectionContribution[]>): boolean {178179if (!Array.isArray(user.value)) {180user.collector.error(localize('invalidData', "Expected an array of MCP collections"));181return false;182}183184for (const contribution of user.value) {185if (typeof contribution.id !== 'string' || isFalsyOrWhitespace(contribution.id)) {186user.collector.error(localize('invalidId', "Expected 'id' to be a non-empty string."));187return false;188}189if (typeof contribution.label !== 'string' || isFalsyOrWhitespace(contribution.label)) {190user.collector.error(localize('invalidLabel', "Expected 'label' to be a non-empty string."));191return false;192}193if (contribution.when !== undefined && (typeof contribution.when !== 'string' || isFalsyOrWhitespace(contribution.when))) {194user.collector.error(localize('invalidWhen', "Expected 'when' to be a non-empty string."));195return false;196}197}198199return true;200}201}202203204