Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts
3296 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 { assertNever } from '../../../../base/common/assert.js';
7
import { Codicon } from '../../../../base/common/codicons.js';
8
import { Emitter } from '../../../../base/common/event.js';
9
import { MarkdownString } from '../../../../base/common/htmlContent.js';
10
import { Iterable } from '../../../../base/common/iterator.js';
11
import { Lazy } from '../../../../base/common/lazy.js';
12
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
13
import { derived, IObservable, observableValue, autorunSelfDisposable } from '../../../../base/common/observable.js';
14
import { isDefined } from '../../../../base/common/types.js';
15
import { URI } from '../../../../base/common/uri.js';
16
import { localize } from '../../../../nls.js';
17
import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
18
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
19
import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';
20
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
21
import { ILabelService } from '../../../../platform/label/common/label.js';
22
import { ILogService } from '../../../../platform/log/common/log.js';
23
import { mcpAccessConfig, McpAccessValue } from '../../../../platform/mcp/common/mcpManagement.js';
24
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
25
import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';
26
import { IQuickInputButton, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
27
import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
28
import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js';
29
import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js';
30
import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js';
31
import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js';
32
import { IMcpDevModeDebugging } from './mcpDevMode.js';
33
import { McpRegistryInputStorage } from './mcpRegistryInputStorage.js';
34
import { IMcpHostDelegate, IMcpRegistry, IMcpResolveConnectionOptions } from './mcpRegistryTypes.js';
35
import { McpServerConnection } from './mcpServerConnection.js';
36
import { IMcpServerConnection, LazyCollectionState, McpCollectionDefinition, McpDefinitionReference, McpServerDefinition, McpServerLaunch, McpServerTrust, McpStartServerInteraction } from './mcpTypes.js';
37
38
const notTrustedNonce = '__vscode_not_trusted';
39
40
export class McpRegistry extends Disposable implements IMcpRegistry {
41
declare public readonly _serviceBrand: undefined;
42
43
private readonly _collections = observableValue<readonly McpCollectionDefinition[]>('collections', []);
44
private readonly _delegates = observableValue<readonly IMcpHostDelegate[]>('delegates', []);
45
private readonly _mcpAccessValue: IObservable<string>;
46
public readonly collections: IObservable<readonly McpCollectionDefinition[]> = derived(reader => {
47
if (this._mcpAccessValue.read(reader) === McpAccessValue.None) {
48
return [];
49
}
50
return this._collections.read(reader);
51
});
52
53
private readonly _workspaceStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.WORKSPACE, StorageTarget.USER)));
54
private readonly _profileStorage = new Lazy(() => this._register(this._instantiationService.createInstance(McpRegistryInputStorage, StorageScope.PROFILE, StorageTarget.USER)));
55
56
private readonly _ongoingLazyActivations = observableValue(this, 0);
57
58
public readonly lazyCollectionState = derived(reader => {
59
if (this._mcpAccessValue.read(reader) === McpAccessValue.None) {
60
return { state: LazyCollectionState.AllKnown, collections: [] };
61
}
62
63
if (this._ongoingLazyActivations.read(reader) > 0) {
64
return { state: LazyCollectionState.LoadingUnknown, collections: [] };
65
}
66
const collections = this._collections.read(reader);
67
const hasUnknown = collections.some(c => c.lazy && c.lazy.isCached === false);
68
return hasUnknown ? { state: LazyCollectionState.HasUnknown, collections: collections.filter(c => c.lazy && c.lazy.isCached === false) } : { state: LazyCollectionState.AllKnown, collections: [] };
69
});
70
71
public get delegates(): IObservable<readonly IMcpHostDelegate[]> {
72
return this._delegates;
73
}
74
75
private readonly _onDidChangeInputs = this._register(new Emitter<void>());
76
public readonly onDidChangeInputs = this._onDidChangeInputs.event;
77
78
constructor(
79
@IInstantiationService private readonly _instantiationService: IInstantiationService,
80
@IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService,
81
@IDialogService private readonly _dialogService: IDialogService,
82
@INotificationService private readonly _notificationService: INotificationService,
83
@IEditorService private readonly _editorService: IEditorService,
84
@IConfigurationService configurationService: IConfigurationService,
85
@IQuickInputService private readonly _quickInputService: IQuickInputService,
86
@ILabelService private readonly _labelService: ILabelService,
87
@ILogService private readonly _logService: ILogService,
88
) {
89
super();
90
this._mcpAccessValue = observableConfigValue(mcpAccessConfig, McpAccessValue.All, configurationService);
91
}
92
93
public registerDelegate(delegate: IMcpHostDelegate): IDisposable {
94
const delegates = this._delegates.get().slice();
95
delegates.push(delegate);
96
delegates.sort((a, b) => b.priority - a.priority);
97
this._delegates.set(delegates, undefined);
98
99
return {
100
dispose: () => {
101
const delegates = this._delegates.get().filter(d => d !== delegate);
102
this._delegates.set(delegates, undefined);
103
}
104
};
105
}
106
107
public registerCollection(collection: McpCollectionDefinition): IDisposable {
108
const currentCollections = this._collections.get();
109
const toReplace = currentCollections.find(c => c.lazy && c.id === collection.id);
110
111
// Incoming collections replace the "lazy" versions. See `ExtensionMcpDiscovery` for an example.
112
if (toReplace) {
113
this._collections.set(currentCollections.map(c => c === toReplace ? collection : c), undefined);
114
} else {
115
this._collections.set([...currentCollections, collection]
116
.sort((a, b) => (a.presentation?.order || 0) - (b.presentation?.order || 0)), undefined);
117
}
118
119
return {
120
dispose: () => {
121
const currentCollections = this._collections.get();
122
this._collections.set(currentCollections.filter(c => c !== collection), undefined);
123
}
124
};
125
}
126
127
public getServerDefinition(collectionRef: McpDefinitionReference, definitionRef: McpDefinitionReference): IObservable<{ server: McpServerDefinition | undefined; collection: McpCollectionDefinition | undefined }> {
128
const collectionObs = this._collections.map(cols => cols.find(c => c.id === collectionRef.id));
129
return collectionObs.map((collection, reader) => {
130
const server = collection?.serverDefinitions.read(reader).find(s => s.id === definitionRef.id);
131
return { collection, server };
132
});
133
}
134
135
public async discoverCollections(): Promise<McpCollectionDefinition[]> {
136
const toDiscover = this._collections.get().filter(c => c.lazy && !c.lazy.isCached);
137
138
this._ongoingLazyActivations.set(this._ongoingLazyActivations.get() + 1, undefined);
139
await Promise.all(toDiscover.map(c => c.lazy?.load())).finally(() => {
140
this._ongoingLazyActivations.set(this._ongoingLazyActivations.get() - 1, undefined);
141
});
142
143
const found: McpCollectionDefinition[] = [];
144
const current = this._collections.get();
145
for (const collection of toDiscover) {
146
const rec = current.find(c => c.id === collection.id);
147
if (!rec) {
148
// ignored
149
} else if (rec.lazy) {
150
rec.lazy.removed?.(); // did not get replaced by the non-lazy version
151
} else {
152
found.push(rec);
153
}
154
}
155
156
157
return found;
158
}
159
160
private _getInputStorage(scope: StorageScope): McpRegistryInputStorage {
161
return scope === StorageScope.WORKSPACE ? this._workspaceStorage.value : this._profileStorage.value;
162
}
163
164
private _getInputStorageInConfigTarget(configTarget: ConfigurationTarget): McpRegistryInputStorage {
165
return this._getInputStorage(
166
configTarget === ConfigurationTarget.WORKSPACE || configTarget === ConfigurationTarget.WORKSPACE_FOLDER
167
? StorageScope.WORKSPACE
168
: StorageScope.PROFILE
169
);
170
}
171
172
public async clearSavedInputs(scope: StorageScope, inputId?: string) {
173
const storage = this._getInputStorage(scope);
174
if (inputId) {
175
await storage.clear(inputId);
176
} else {
177
storage.clearAll();
178
}
179
180
this._onDidChangeInputs.fire();
181
}
182
183
public async editSavedInput(inputId: string, folderData: IWorkspaceFolderData | undefined, configSection: string, target: ConfigurationTarget): Promise<void> {
184
const storage = this._getInputStorageInConfigTarget(target);
185
const expr = ConfigurationResolverExpression.parse(inputId);
186
187
const stored = await storage.getMap();
188
const previous = stored[inputId].value;
189
await this._configurationResolverService.resolveWithInteraction(folderData, expr, configSection, previous ? { [inputId.slice(2, -1)]: previous } : {}, target);
190
await this._updateStorageWithExpressionInputs(storage, expr);
191
}
192
193
public async setSavedInput(inputId: string, target: ConfigurationTarget, value: string): Promise<void> {
194
const storage = this._getInputStorageInConfigTarget(target);
195
const expr = ConfigurationResolverExpression.parse(inputId);
196
for (const unresolved of expr.unresolved()) {
197
expr.resolve(unresolved, value);
198
break;
199
}
200
await this._updateStorageWithExpressionInputs(storage, expr);
201
}
202
203
public getSavedInputs(scope: StorageScope): Promise<{ [id: string]: IResolvedValue }> {
204
return this._getInputStorage(scope).getMap();
205
}
206
207
private async _checkTrust(collection: McpCollectionDefinition, definition: McpServerDefinition, {
208
trustNonceBearer,
209
interaction,
210
promptType = 'only-new',
211
autoTrustChanges = false,
212
}: IMcpResolveConnectionOptions) {
213
if (collection.trustBehavior === McpServerTrust.Kind.Trusted) {
214
this._logService.trace(`MCP server ${definition.id} is trusted, no trust prompt needed`);
215
return true;
216
} else if (collection.trustBehavior === McpServerTrust.Kind.TrustedOnNonce) {
217
if (definition.cacheNonce === trustNonceBearer.trustedAtNonce) {
218
this._logService.trace(`MCP server ${definition.id} is unchanged, no trust prompt needed`);
219
return true;
220
}
221
222
if (autoTrustChanges) {
223
this._logService.trace(`MCP server ${definition.id} is was changed but user explicitly executed`);
224
trustNonceBearer.trustedAtNonce = definition.cacheNonce;
225
return true;
226
}
227
228
if (trustNonceBearer.trustedAtNonce === notTrustedNonce) {
229
if (promptType === 'all-untrusted') {
230
return this._promptForTrust(definition, collection, interaction, trustNonceBearer);
231
} else {
232
this._logService.trace(`MCP server ${definition.id} is untrusted, denying trust prompt`);
233
return false;
234
}
235
}
236
237
if (promptType === 'never') {
238
this._logService.trace(`MCP server ${definition.id} trust state is unknown, skipping prompt`);
239
return false;
240
}
241
242
const didTrust = await this._promptForTrust(definition, collection, interaction, trustNonceBearer);
243
if (didTrust) {
244
return true;
245
}
246
if (didTrust === undefined) {
247
return undefined;
248
}
249
250
trustNonceBearer.trustedAtNonce = notTrustedNonce;
251
return false;
252
} else {
253
assertNever(collection.trustBehavior);
254
}
255
}
256
257
private async _promptForTrust(definition: McpServerDefinition, collection: McpCollectionDefinition, interaction: McpStartServerInteraction | undefined, trustNonceBearer: { trustedAtNonce: string | undefined }): Promise<boolean> {
258
interaction ??= new McpStartServerInteraction();
259
interaction.participants.set(definition.id, { s: 'waiting', definition, collection });
260
261
const trustedDefinitionIds = await new Promise<string[] | undefined>(resolve => {
262
autorunSelfDisposable(reader => {
263
const map = interaction.participants.observable.read(reader);
264
if (Iterable.some(map.values(), p => p.s === 'unknown')) {
265
return; // wait to gather all calls
266
}
267
268
reader.dispose();
269
interaction.choice ??= this._promptForTrustOpenDialog(
270
[...map.values()].map((v) => v.s === 'waiting' ? v : undefined).filter(isDefined),
271
);
272
resolve(interaction.choice);
273
});
274
});
275
276
this._logService.trace(`MCP trusted servers:`, trustedDefinitionIds);
277
278
if (trustedDefinitionIds) {
279
trustNonceBearer.trustedAtNonce = trustedDefinitionIds.includes(definition.id)
280
? definition.cacheNonce
281
: notTrustedNonce;
282
}
283
284
return !!trustedDefinitionIds?.includes(definition.id);
285
}
286
287
/**
288
* Confirms with the user which of the provided definitions should be trusted.
289
* Returns undefined if the user cancelled the flow, or the list of trusted
290
* definition IDs otherwise.
291
*/
292
protected async _promptForTrustOpenDialog(definitions: { definition: McpServerDefinition; collection: McpCollectionDefinition }[]): Promise<string[] | undefined> {
293
function labelFor(r: { definition: McpServerDefinition; collection: McpCollectionDefinition }) {
294
const originURI = r.definition.presentation?.origin?.uri || r.collection.presentation?.origin;
295
let labelWithOrigin = originURI ? `[\`${r.definition.label}\`](${originURI})` : '`' + r.definition.label + '`';
296
297
if (r.collection.source instanceof ExtensionIdentifier) {
298
labelWithOrigin += ` (${localize('trustFromExt', 'from {0}', r.collection.source.value)})`;
299
}
300
301
return labelWithOrigin;
302
}
303
304
if (definitions.length === 1) {
305
const def = definitions[0];
306
const originURI = def.definition.presentation?.origin?.uri;
307
308
const { result } = await this._dialogService.prompt(
309
{
310
message: localize('trustTitleWithOrigin', 'Trust and run MCP server {0}?', def.definition.label),
311
custom: {
312
icon: Codicon.shield,
313
markdownDetails: [{
314
markdown: 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))),
315
actionHandler: () => {
316
const editor = this._editorService.openEditor({ resource: originURI! }, AUX_WINDOW_GROUP);
317
return editor.then(Boolean);
318
},
319
}]
320
},
321
buttons: [
322
{ label: localize('mcp.trust.yes', 'Trust'), run: () => true },
323
{ label: localize('mcp.trust.no', 'Do not trust'), run: () => false }
324
],
325
},
326
);
327
328
return result === undefined ? undefined : (result ? [def.definition.id] : []);
329
}
330
331
const list = definitions.map(d => `- ${labelFor(d)}`).join('\n');
332
const { result } = await this._dialogService.prompt(
333
{
334
message: localize('trustTitleWithOriginMulti', 'Trust and run {0} MCP servers?', definitions.length),
335
custom: {
336
icon: Codicon.shield,
337
markdownDetails: [{
338
markdown: 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)),
339
actionHandler: (uri) => {
340
const editor = this._editorService.openEditor({ resource: URI.parse(uri) }, AUX_WINDOW_GROUP);
341
return editor.then(Boolean);
342
},
343
}]
344
},
345
buttons: [
346
{ label: localize('mcp.trust.yes', 'Trust'), run: () => 'all' },
347
{ label: localize('mcp.trust.pick', 'Pick Trusted'), run: () => 'pick' },
348
{ label: localize('mcp.trust.no', 'Do not trust'), run: () => 'none' },
349
],
350
},
351
);
352
353
if (result === undefined) {
354
return undefined;
355
} else if (result === 'all') {
356
return definitions.map(d => d.definition.id);
357
} else if (result === 'none') {
358
return [];
359
}
360
361
type ActionableButton = IQuickInputButton & { action: () => void };
362
function isActionableButton(obj: IQuickInputButton): obj is ActionableButton {
363
return typeof (obj as ActionableButton).action === 'function';
364
}
365
366
const store = new DisposableStore();
367
const picker = store.add(this._quickInputService.createQuickPick<IQuickPickItem & { definitonId: string }>({ useSeparators: false }));
368
picker.canSelectMany = true;
369
picker.items = definitions.map(({ definition, collection }) => {
370
const buttons: ActionableButton[] = [];
371
if (definition.presentation?.origin) {
372
const origin = definition.presentation.origin;
373
buttons.push({
374
iconClass: 'codicon-go-to-file',
375
tooltip: 'Go to Definition',
376
action: () => this._editorService.openEditor({ resource: origin.uri, options: { selection: origin.range } })
377
});
378
}
379
380
return {
381
type: 'item',
382
label: definition.label,
383
definitonId: definition.id,
384
description: collection.source instanceof ExtensionIdentifier
385
? collection.source.value
386
: (definition.presentation?.origin ? this._labelService.getUriLabel(definition.presentation.origin.uri) : undefined),
387
picked: false,
388
buttons
389
};
390
});
391
picker.placeholder = 'Select MCP servers to trust';
392
picker.ignoreFocusOut = true;
393
394
store.add(picker.onDidTriggerItemButton(e => {
395
if (isActionableButton(e.button)) {
396
e.button.action();
397
}
398
}));
399
400
return new Promise<string[] | undefined>(resolve => {
401
picker.onDidAccept(() => {
402
resolve(picker.selectedItems.map(item => item.definitonId));
403
picker.hide();
404
});
405
picker.onDidHide(() => {
406
resolve(undefined);
407
});
408
picker.show();
409
}).finally(() => store.dispose());
410
}
411
412
private async _updateStorageWithExpressionInputs(inputStorage: McpRegistryInputStorage, expr: ConfigurationResolverExpression<unknown>): Promise<void> {
413
const secrets: Record<string, IResolvedValue> = {};
414
const inputs: Record<string, IResolvedValue> = {};
415
for (const [replacement, resolved] of expr.resolved()) {
416
if (resolved.input?.type === 'promptString' && resolved.input.password) {
417
secrets[replacement.id] = resolved;
418
} else {
419
inputs[replacement.id] = resolved;
420
}
421
}
422
423
inputStorage.setPlainText(inputs);
424
await inputStorage.setSecrets(secrets);
425
this._onDidChangeInputs.fire();
426
}
427
428
private async _replaceVariablesInLaunch(definition: McpServerDefinition, launch: McpServerLaunch) {
429
if (!definition.variableReplacement) {
430
return launch;
431
}
432
433
const { section, target, folder } = definition.variableReplacement;
434
const inputStorage = this._getInputStorageInConfigTarget(target);
435
const previouslyStored = await inputStorage.getMap();
436
437
// pre-fill the variables we already resolved to avoid extra prompting
438
const expr = ConfigurationResolverExpression.parse(launch);
439
for (const replacement of expr.unresolved()) {
440
if (previouslyStored.hasOwnProperty(replacement.id)) {
441
expr.resolve(replacement, previouslyStored[replacement.id]);
442
}
443
}
444
445
// resolve variables requiring user input
446
await this._configurationResolverService.resolveWithInteraction(folder, expr, section, undefined, target);
447
448
await this._updateStorageWithExpressionInputs(inputStorage, expr);
449
450
// resolve other non-interactive variables, returning the final object
451
return await this._configurationResolverService.resolveAsync(folder, expr);
452
}
453
454
public async resolveConnection(opts: IMcpResolveConnectionOptions): Promise<IMcpServerConnection | undefined> {
455
const { collectionRef, definitionRef, interaction, logger, debug } = opts;
456
let collection = this._collections.get().find(c => c.id === collectionRef.id);
457
if (collection?.lazy) {
458
await collection.lazy.load();
459
collection = this._collections.get().find(c => c.id === collectionRef.id);
460
}
461
462
const definition = collection?.serverDefinitions.get().find(s => s.id === definitionRef.id);
463
if (!collection || !definition) {
464
throw new Error(`Collection or definition not found for ${collectionRef.id} and ${definitionRef.id}`);
465
}
466
467
const delegate = this._delegates.get().find(d => d.canStart(collection, definition));
468
if (!delegate) {
469
throw new Error('No delegate found that can handle the connection');
470
}
471
472
const trusted = await this._checkTrust(collection, definition, opts);
473
interaction?.participants.set(definition.id, { s: 'resolved' });
474
if (!trusted) {
475
return undefined;
476
}
477
478
let launch: McpServerLaunch | undefined = definition.launch;
479
if (collection.resolveServerLanch) {
480
launch = await collection.resolveServerLanch(definition);
481
if (!launch) {
482
return undefined; // interaction cancelled by user
483
}
484
}
485
486
try {
487
launch = await this._replaceVariablesInLaunch(definition, launch);
488
489
if (definition.devMode && debug) {
490
launch = await this._instantiationService.invokeFunction(accessor => accessor.get(IMcpDevModeDebugging).transform(definition, launch!));
491
}
492
} catch (e) {
493
this._notificationService.notify({
494
severity: Severity.Error,
495
message: localize('mcp.launchError', 'Error starting {0}: {1}', definition.label, String(e)),
496
actions: {
497
primary: collection.presentation?.origin && [
498
{
499
id: 'mcp.launchError.openConfig',
500
class: undefined,
501
enabled: true,
502
tooltip: '',
503
label: localize('mcp.launchError.openConfig', 'Open Configuration'),
504
run: () => this._editorService.openEditor({
505
resource: collection.presentation!.origin,
506
options: { selection: definition.presentation?.origin?.range }
507
}),
508
}
509
]
510
}
511
});
512
return;
513
}
514
515
return this._instantiationService.createInstance(
516
McpServerConnection,
517
collection,
518
definition,
519
delegate,
520
launch,
521
logger,
522
);
523
}
524
}
525
526