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
5319 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, UserInteractionRequiredError } 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.id === collection.id);
110
111
// Incoming collections replace the "lazy" versions. See `ExtensionMcpDiscovery` for an example.
112
if (toReplace && !toReplace.lazy) {
113
return Disposable.None;
114
} else if (toReplace) {
115
this._collections.set(currentCollections.map(c => c === toReplace ? collection : c), undefined);
116
} else {
117
this._collections.set([...currentCollections, collection]
118
.sort((a, b) => (a.presentation?.order || 0) - (b.presentation?.order || 0)), undefined);
119
}
120
121
return {
122
dispose: () => {
123
const currentCollections = this._collections.get();
124
this._collections.set(currentCollections.filter(c => c !== collection), undefined);
125
}
126
};
127
}
128
129
public getServerDefinition(collectionRef: McpDefinitionReference, definitionRef: McpDefinitionReference): IObservable<{ server: McpServerDefinition | undefined; collection: McpCollectionDefinition | undefined }> {
130
const collectionObs = this._collections.map(cols => cols.find(c => c.id === collectionRef.id));
131
return collectionObs.map((collection, reader) => {
132
const server = collection?.serverDefinitions.read(reader).find(s => s.id === definitionRef.id);
133
return { collection, server };
134
});
135
}
136
137
public async discoverCollections(): Promise<McpCollectionDefinition[]> {
138
const toDiscover = this._collections.get().filter(c => c.lazy && !c.lazy.isCached);
139
140
this._ongoingLazyActivations.set(this._ongoingLazyActivations.get() + 1, undefined);
141
await Promise.all(toDiscover.map(c => c.lazy?.load())).finally(() => {
142
this._ongoingLazyActivations.set(this._ongoingLazyActivations.get() - 1, undefined);
143
});
144
145
const found: McpCollectionDefinition[] = [];
146
const current = this._collections.get();
147
for (const collection of toDiscover) {
148
const rec = current.find(c => c.id === collection.id);
149
if (!rec) {
150
// ignored
151
} else if (rec.lazy) {
152
rec.lazy.removed?.(); // did not get replaced by the non-lazy version
153
} else {
154
found.push(rec);
155
}
156
}
157
158
159
return found;
160
}
161
162
private _getInputStorage(scope: StorageScope): McpRegistryInputStorage {
163
return scope === StorageScope.WORKSPACE ? this._workspaceStorage.value : this._profileStorage.value;
164
}
165
166
private _getInputStorageInConfigTarget(configTarget: ConfigurationTarget): McpRegistryInputStorage {
167
return this._getInputStorage(
168
configTarget === ConfigurationTarget.WORKSPACE || configTarget === ConfigurationTarget.WORKSPACE_FOLDER
169
? StorageScope.WORKSPACE
170
: StorageScope.PROFILE
171
);
172
}
173
174
public async clearSavedInputs(scope: StorageScope, inputId?: string) {
175
const storage = this._getInputStorage(scope);
176
if (inputId) {
177
await storage.clear(inputId);
178
} else {
179
storage.clearAll();
180
}
181
182
this._onDidChangeInputs.fire();
183
}
184
185
public async editSavedInput(inputId: string, folderData: IWorkspaceFolderData | undefined, configSection: string, target: ConfigurationTarget): Promise<void> {
186
const storage = this._getInputStorageInConfigTarget(target);
187
const expr = ConfigurationResolverExpression.parse(inputId);
188
189
const stored = await storage.getMap();
190
const previous = stored[inputId].value;
191
await this._configurationResolverService.resolveWithInteraction(folderData, expr, configSection, previous ? { [inputId.slice(2, -1)]: previous } : {}, target);
192
await this._updateStorageWithExpressionInputs(storage, expr);
193
}
194
195
public async setSavedInput(inputId: string, target: ConfigurationTarget, value: string): Promise<void> {
196
const storage = this._getInputStorageInConfigTarget(target);
197
const expr = ConfigurationResolverExpression.parse(inputId);
198
for (const unresolved of expr.unresolved()) {
199
expr.resolve(unresolved, value);
200
break;
201
}
202
await this._updateStorageWithExpressionInputs(storage, expr);
203
}
204
205
public getSavedInputs(scope: StorageScope): Promise<{ [id: string]: IResolvedValue }> {
206
return this._getInputStorage(scope).getMap();
207
}
208
209
private async _checkTrust(collection: McpCollectionDefinition, definition: McpServerDefinition, {
210
trustNonceBearer,
211
interaction,
212
promptType = 'only-new',
213
autoTrustChanges = false,
214
errorOnUserInteraction = false,
215
}: IMcpResolveConnectionOptions) {
216
if (collection.trustBehavior === McpServerTrust.Kind.Trusted) {
217
this._logService.trace(`MCP server ${definition.id} is trusted, no trust prompt needed`);
218
return true;
219
} else if (collection.trustBehavior === McpServerTrust.Kind.TrustedOnNonce) {
220
if (definition.cacheNonce === trustNonceBearer.trustedAtNonce) {
221
this._logService.trace(`MCP server ${definition.id} is unchanged, no trust prompt needed`);
222
return true;
223
}
224
225
if (autoTrustChanges) {
226
this._logService.trace(`MCP server ${definition.id} is was changed but user explicitly executed`);
227
trustNonceBearer.trustedAtNonce = definition.cacheNonce;
228
return true;
229
}
230
231
if (trustNonceBearer.trustedAtNonce === notTrustedNonce) {
232
if (promptType === 'all-untrusted') {
233
if (errorOnUserInteraction) {
234
throw new UserInteractionRequiredError('serverTrust');
235
}
236
return this._promptForTrust(definition, collection, interaction, trustNonceBearer);
237
} else {
238
this._logService.trace(`MCP server ${definition.id} is untrusted, denying trust prompt`);
239
return false;
240
}
241
}
242
243
if (promptType === 'never') {
244
this._logService.trace(`MCP server ${definition.id} trust state is unknown, skipping prompt`);
245
return false;
246
}
247
248
if (errorOnUserInteraction) {
249
throw new UserInteractionRequiredError('serverTrust');
250
}
251
252
const didTrust = await this._promptForTrust(definition, collection, interaction, trustNonceBearer);
253
if (didTrust) {
254
return true;
255
}
256
if (didTrust === undefined) {
257
return undefined;
258
}
259
260
trustNonceBearer.trustedAtNonce = notTrustedNonce;
261
return false;
262
} else {
263
assertNever(collection.trustBehavior);
264
}
265
}
266
267
private async _promptForTrust(definition: McpServerDefinition, collection: McpCollectionDefinition, interaction: McpStartServerInteraction | undefined, trustNonceBearer: { trustedAtNonce: string | undefined }): Promise<boolean> {
268
interaction ??= new McpStartServerInteraction();
269
interaction.participants.set(definition.id, { s: 'waiting', definition, collection });
270
271
const trustedDefinitionIds = await new Promise<string[] | undefined>(resolve => {
272
autorunSelfDisposable(reader => {
273
const map = interaction.participants.observable.read(reader);
274
if (Iterable.some(map.values(), p => p.s === 'unknown')) {
275
return; // wait to gather all calls
276
}
277
278
reader.dispose();
279
interaction.choice ??= this._promptForTrustOpenDialog(
280
[...map.values()].map((v) => v.s === 'waiting' ? v : undefined).filter(isDefined),
281
);
282
resolve(interaction.choice);
283
});
284
});
285
286
this._logService.trace(`MCP trusted servers:`, trustedDefinitionIds);
287
288
if (trustedDefinitionIds) {
289
trustNonceBearer.trustedAtNonce = trustedDefinitionIds.includes(definition.id)
290
? definition.cacheNonce
291
: notTrustedNonce;
292
}
293
294
return !!trustedDefinitionIds?.includes(definition.id);
295
}
296
297
/**
298
* Confirms with the user which of the provided definitions should be trusted.
299
* Returns undefined if the user cancelled the flow, or the list of trusted
300
* definition IDs otherwise.
301
*/
302
protected async _promptForTrustOpenDialog(definitions: { definition: McpServerDefinition; collection: McpCollectionDefinition }[]): Promise<string[] | undefined> {
303
function labelFor(r: { definition: McpServerDefinition; collection: McpCollectionDefinition }) {
304
const originURI = r.definition.presentation?.origin?.uri || r.collection.presentation?.origin;
305
let labelWithOrigin = originURI ? `[\`${r.definition.label}\`](${originURI})` : '`' + r.definition.label + '`';
306
307
if (r.collection.source instanceof ExtensionIdentifier) {
308
labelWithOrigin += ` (${localize('trustFromExt', 'from {0}', r.collection.source.value)})`;
309
}
310
311
return labelWithOrigin;
312
}
313
314
if (definitions.length === 1) {
315
const def = definitions[0];
316
const originURI = def.definition.presentation?.origin?.uri;
317
318
const { result } = await this._dialogService.prompt(
319
{
320
message: localize('trustTitleWithOrigin', 'Trust and run MCP server {0}?', def.definition.label),
321
custom: {
322
icon: Codicon.shield,
323
markdownDetails: [{
324
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))),
325
actionHandler: () => {
326
const editor = this._editorService.openEditor({ resource: originURI! }, AUX_WINDOW_GROUP);
327
return editor.then(Boolean);
328
},
329
}]
330
},
331
buttons: [
332
{ label: localize('mcp.trust.yes', 'Trust'), run: () => true },
333
{ label: localize('mcp.trust.no', 'Do not trust'), run: () => false }
334
],
335
},
336
);
337
338
return result === undefined ? undefined : (result ? [def.definition.id] : []);
339
}
340
341
const list = definitions.map(d => `- ${labelFor(d)}`).join('\n');
342
const { result } = await this._dialogService.prompt(
343
{
344
message: localize('trustTitleWithOriginMulti', 'Trust and run {0} MCP servers?', definitions.length),
345
custom: {
346
icon: Codicon.shield,
347
markdownDetails: [{
348
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)),
349
actionHandler: (uri) => {
350
const editor = this._editorService.openEditor({ resource: URI.parse(uri) }, AUX_WINDOW_GROUP);
351
return editor.then(Boolean);
352
},
353
}]
354
},
355
buttons: [
356
{ label: localize('mcp.trust.yes', 'Trust'), run: () => 'all' },
357
{ label: localize('mcp.trust.pick', 'Pick Trusted'), run: () => 'pick' },
358
{ label: localize('mcp.trust.no', 'Do not trust'), run: () => 'none' },
359
],
360
},
361
);
362
363
if (result === undefined) {
364
return undefined;
365
} else if (result === 'all') {
366
return definitions.map(d => d.definition.id);
367
} else if (result === 'none') {
368
return [];
369
}
370
371
type ActionableButton = IQuickInputButton & { action: () => void };
372
function isActionableButton(obj: IQuickInputButton): obj is ActionableButton {
373
return typeof (obj as ActionableButton).action === 'function';
374
}
375
376
const store = new DisposableStore();
377
const picker = store.add(this._quickInputService.createQuickPick<IQuickPickItem & { definitonId: string }>({ useSeparators: false }));
378
picker.canSelectMany = true;
379
picker.items = definitions.map(({ definition, collection }) => {
380
const buttons: ActionableButton[] = [];
381
if (definition.presentation?.origin) {
382
const origin = definition.presentation.origin;
383
buttons.push({
384
iconClass: 'codicon-go-to-file',
385
tooltip: 'Go to Definition',
386
action: () => this._editorService.openEditor({ resource: origin.uri, options: { selection: origin.range } })
387
});
388
}
389
390
return {
391
type: 'item',
392
label: definition.label,
393
definitonId: definition.id,
394
description: collection.source instanceof ExtensionIdentifier
395
? collection.source.value
396
: (definition.presentation?.origin ? this._labelService.getUriLabel(definition.presentation.origin.uri) : undefined),
397
picked: false,
398
buttons
399
};
400
});
401
picker.placeholder = 'Select MCP servers to trust';
402
picker.ignoreFocusOut = true;
403
404
store.add(picker.onDidTriggerItemButton(e => {
405
if (isActionableButton(e.button)) {
406
e.button.action();
407
}
408
}));
409
410
return new Promise<string[] | undefined>(resolve => {
411
store.add(picker.onDidAccept(() => {
412
resolve(picker.selectedItems.map(item => item.definitonId));
413
picker.hide();
414
}));
415
store.add(picker.onDidHide(() => {
416
resolve(undefined);
417
}));
418
picker.show();
419
}).finally(() => store.dispose());
420
}
421
422
private async _updateStorageWithExpressionInputs(inputStorage: McpRegistryInputStorage, expr: ConfigurationResolverExpression<unknown>): Promise<void> {
423
const secrets: Record<string, IResolvedValue> = {};
424
const inputs: Record<string, IResolvedValue> = {};
425
for (const [replacement, resolved] of expr.resolved()) {
426
if (resolved.input?.type === 'promptString' && resolved.input.password) {
427
secrets[replacement.id] = resolved;
428
} else {
429
inputs[replacement.id] = resolved;
430
}
431
}
432
433
inputStorage.setPlainText(inputs);
434
await inputStorage.setSecrets(secrets);
435
this._onDidChangeInputs.fire();
436
}
437
438
private async _replaceVariablesInLaunch(delegate: IMcpHostDelegate, definition: McpServerDefinition, launch: McpServerLaunch, errorOnUserInteraction?: boolean) {
439
if (!definition.variableReplacement) {
440
return launch;
441
}
442
443
const { section, target, folder } = definition.variableReplacement;
444
const inputStorage = this._getInputStorageInConfigTarget(target);
445
const [previouslyStored, withRemoteFilled] = await Promise.all([
446
inputStorage.getMap(),
447
delegate.substituteVariables(definition, launch),
448
]);
449
450
// pre-fill the variables we already resolved to avoid extra prompting
451
const expr = ConfigurationResolverExpression.parse(withRemoteFilled);
452
for (const replacement of expr.unresolved()) {
453
if (previouslyStored.hasOwnProperty(replacement.id)) {
454
expr.resolve(replacement, previouslyStored[replacement.id]);
455
}
456
}
457
458
// Check if there are still unresolved variables that would require interaction
459
if (errorOnUserInteraction) {
460
const unresolved = Array.from(expr.unresolved());
461
if (unresolved.length > 0) {
462
throw new UserInteractionRequiredError('variables');
463
}
464
}
465
// resolve variables requiring user input
466
await this._configurationResolverService.resolveWithInteraction(folder, expr, section, undefined, target);
467
468
await this._updateStorageWithExpressionInputs(inputStorage, expr);
469
470
// resolve other non-interactive variables, returning the final object
471
return await this._configurationResolverService.resolveAsync(folder, expr);
472
}
473
474
public async resolveConnection(opts: IMcpResolveConnectionOptions): Promise<IMcpServerConnection | undefined> {
475
const { collectionRef, definitionRef, interaction, logger, debug } = opts;
476
let collection = this._collections.get().find(c => c.id === collectionRef.id);
477
if (collection?.lazy) {
478
await collection.lazy.load();
479
collection = this._collections.get().find(c => c.id === collectionRef.id);
480
}
481
482
const definition = collection?.serverDefinitions.get().find(s => s.id === definitionRef.id);
483
if (!collection || !definition) {
484
throw new Error(`Collection or definition not found for ${collectionRef.id} and ${definitionRef.id}`);
485
}
486
487
const delegate = this._delegates.get().find(d => d.canStart(collection, definition));
488
if (!delegate) {
489
throw new Error('No delegate found that can handle the connection');
490
}
491
492
const trusted = await this._checkTrust(collection, definition, opts);
493
interaction?.participants.set(definition.id, { s: 'resolved' });
494
if (!trusted) {
495
return undefined;
496
}
497
498
let launch: McpServerLaunch | undefined = definition.launch;
499
if (collection.resolveServerLanch) {
500
launch = await collection.resolveServerLanch(definition);
501
if (!launch) {
502
return undefined; // interaction cancelled by user
503
}
504
}
505
506
try {
507
launch = await this._replaceVariablesInLaunch(delegate, definition, launch, opts.errorOnUserInteraction);
508
509
if (definition.devMode && debug) {
510
launch = await this._instantiationService.invokeFunction(accessor => accessor.get(IMcpDevModeDebugging).transform(definition, launch!));
511
}
512
} catch (e) {
513
if (e instanceof UserInteractionRequiredError) {
514
throw e;
515
}
516
517
this._notificationService.notify({
518
severity: Severity.Error,
519
message: localize('mcp.launchError', 'Error starting {0}: {1}', definition.label, String(e)),
520
actions: {
521
primary: collection.presentation?.origin && [
522
{
523
id: 'mcp.launchError.openConfig',
524
class: undefined,
525
enabled: true,
526
tooltip: '',
527
label: localize('mcp.launchError.openConfig', 'Open Configuration'),
528
run: () => this._editorService.openEditor({
529
resource: collection.presentation!.origin,
530
options: { selection: definition.presentation?.origin?.range }
531
}),
532
}
533
]
534
}
535
});
536
return;
537
}
538
539
return this._instantiationService.createInstance(
540
McpServerConnection,
541
collection,
542
definition,
543
delegate,
544
launch,
545
logger,
546
opts.errorOnUserInteraction,
547
opts.taskManager,
548
);
549
}
550
}
551
552