Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts
5371 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js';
7
import { observableValue } from '../../../../../base/common/observable.js';
8
import { isFalsyOrWhitespace } from '../../../../../base/common/strings.js';
9
import { localize } from '../../../../../nls.js';
10
import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js';
11
import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
12
import { IMcpCollectionContribution } from '../../../../../platform/extensions/common/extensions.js';
13
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
14
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
15
import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js';
16
import { mcpActivationEvent, mcpContributionPoint } from '../mcpConfiguration.js';
17
import { IMcpRegistry } from '../mcpRegistryTypes.js';
18
import { extensionPrefixedIdentifier, McpServerDefinition, McpServerTrust } from '../mcpTypes.js';
19
import { IMcpDiscovery } from './mcpDiscovery.js';
20
21
const cacheKey = 'mcp.extCachedServers';
22
23
interface IServerCacheEntry {
24
readonly servers: readonly McpServerDefinition.Serialized[];
25
}
26
27
const _mcpExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint(mcpContributionPoint);
28
29
const enum PersistWhen {
30
CollectionExists,
31
Always,
32
}
33
34
export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery {
35
36
readonly fromGallery = false;
37
38
private readonly _extensionCollectionIdsToPersist = new Map<string, PersistWhen>();
39
private readonly cachedServers: { [collcetionId: string]: IServerCacheEntry };
40
private readonly _conditionalCollections = this._register(new DisposableMap<string>());
41
42
constructor(
43
@IMcpRegistry private readonly _mcpRegistry: IMcpRegistry,
44
@IStorageService storageService: IStorageService,
45
@IExtensionService private readonly _extensionService: IExtensionService,
46
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
47
) {
48
super();
49
this.cachedServers = storageService.getObject(cacheKey, StorageScope.WORKSPACE, {});
50
51
this._register(storageService.onWillSaveState(() => {
52
let updated = false;
53
for (const [collectionId, behavior] of this._extensionCollectionIdsToPersist.entries()) {
54
const collection = this._mcpRegistry.collections.get().find(c => c.id === collectionId);
55
let defs = collection?.serverDefinitions.get();
56
if (!collection || collection.lazy) {
57
if (behavior === PersistWhen.Always) {
58
defs = [];
59
} else {
60
continue;
61
}
62
}
63
64
if (defs) {
65
updated = true;
66
this.cachedServers[collectionId] = { servers: defs.map(McpServerDefinition.toSerialized) };
67
}
68
}
69
70
if (updated) {
71
storageService.store(cacheKey, this.cachedServers, StorageScope.WORKSPACE, StorageTarget.MACHINE);
72
}
73
}));
74
}
75
76
public start(): void {
77
const extensionCollections = this._register(new DisposableMap<string>());
78
this._register(_mcpExtensionPoint.setHandler((_extensions, delta) => {
79
const { added, removed } = delta;
80
81
for (const collections of removed) {
82
for (const coll of collections.value) {
83
const id = extensionPrefixedIdentifier(collections.description.identifier, coll.id);
84
extensionCollections.deleteAndDispose(id);
85
this._conditionalCollections.deleteAndDispose(id);
86
}
87
}
88
89
for (const collections of added) {
90
91
if (!ExtensionMcpDiscovery._validate(collections)) {
92
continue;
93
}
94
95
for (const coll of collections.value) {
96
const id = extensionPrefixedIdentifier(collections.description.identifier, coll.id);
97
this._extensionCollectionIdsToPersist.set(id, PersistWhen.CollectionExists);
98
99
// Handle conditional collections with 'when' clause
100
if (coll.when) {
101
this._registerConditionalCollection(id, coll, collections, extensionCollections);
102
} else {
103
// Register collection immediately if no 'when' clause
104
this._registerCollection(id, coll, collections, extensionCollections);
105
}
106
}
107
}
108
}));
109
}
110
111
private _registerCollection(
112
id: string,
113
coll: IMcpCollectionContribution,
114
collections: extensionsRegistry.IExtensionPointUser<IMcpCollectionContribution[]>,
115
extensionCollections: DisposableMap<string>
116
) {
117
const serverDefs = this.cachedServers.hasOwnProperty(id) ? this.cachedServers[id].servers : undefined;
118
const dispo = this._mcpRegistry.registerCollection({
119
id,
120
label: coll.label,
121
remoteAuthority: null,
122
trustBehavior: McpServerTrust.Kind.Trusted,
123
scope: StorageScope.WORKSPACE,
124
configTarget: ConfigurationTarget.USER,
125
serverDefinitions: observableValue<McpServerDefinition[]>(this, serverDefs?.map(McpServerDefinition.fromSerialized) || []),
126
lazy: {
127
isCached: !!serverDefs,
128
load: () => this._activateExtensionServers(coll.id).then(() => {
129
// persist (an empty collection) in case the extension doesn't end up publishing one
130
this._extensionCollectionIdsToPersist.set(id, PersistWhen.Always);
131
}),
132
removed: () => {
133
extensionCollections.deleteAndDispose(id);
134
this._conditionalCollections.deleteAndDispose(id);
135
},
136
},
137
source: collections.description.identifier
138
});
139
140
extensionCollections.set(id, dispo);
141
}
142
143
private _registerConditionalCollection(
144
id: string,
145
coll: IMcpCollectionContribution,
146
collections: extensionsRegistry.IExtensionPointUser<IMcpCollectionContribution[]>,
147
extensionCollections: DisposableMap<string>
148
) {
149
const whenClause = ContextKeyExpr.deserialize(coll.when!);
150
if (!whenClause) {
151
// Invalid when clause, treat as always false
152
return;
153
}
154
155
const evaluate = () => {
156
const nowSatisfied = this._contextKeyService.contextMatchesRules(whenClause);
157
const isRegistered = extensionCollections.has(id);
158
if (nowSatisfied && !isRegistered) {
159
this._registerCollection(id, coll, collections, extensionCollections);
160
} else if (!nowSatisfied && isRegistered) {
161
extensionCollections.deleteAndDispose(id);
162
}
163
};
164
165
const contextKeyListener = this._contextKeyService.onDidChangeContext(evaluate);
166
evaluate();
167
168
// Store disposable for this conditional collection
169
this._conditionalCollections.set(id, contextKeyListener);
170
}
171
172
private async _activateExtensionServers(collectionId: string): Promise<void> {
173
await this._extensionService.activateByEvent(mcpActivationEvent(collectionId));
174
await Promise.all(this._mcpRegistry.delegates.get()
175
.map(r => r.waitForInitialProviderPromises()));
176
}
177
178
private static _validate(user: extensionsRegistry.IExtensionPointUser<IMcpCollectionContribution[]>): boolean {
179
180
if (!Array.isArray(user.value)) {
181
user.collector.error(localize('invalidData', "Expected an array of MCP collections"));
182
return false;
183
}
184
185
for (const contribution of user.value) {
186
if (typeof contribution.id !== 'string' || isFalsyOrWhitespace(contribution.id)) {
187
user.collector.error(localize('invalidId', "Expected 'id' to be a non-empty string."));
188
return false;
189
}
190
if (typeof contribution.label !== 'string' || isFalsyOrWhitespace(contribution.label)) {
191
user.collector.error(localize('invalidLabel', "Expected 'label' to be a non-empty string."));
192
return false;
193
}
194
if (contribution.when !== undefined && (typeof contribution.when !== 'string' || isFalsyOrWhitespace(contribution.when))) {
195
user.collector.error(localize('invalidWhen', "Expected 'when' to be a non-empty string."));
196
return false;
197
}
198
}
199
200
return true;
201
}
202
}
203
204