Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpResourceQuickAccess.ts
5220 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 { DeferredPromise, disposableTimeout, RunOnceScheduler } from '../../../../base/common/async.js';
7
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { Event } from '../../../../base/common/event.js';
10
import { DisposableStore, IDisposable, toDisposable, Disposable } from '../../../../base/common/lifecycle.js';
11
import { autorun, derived, observableValue, IObservable } from '../../../../base/common/observable.js';
12
import { ThemeIcon } from '../../../../base/common/themables.js';
13
import { URI } from '../../../../base/common/uri.js';
14
import { generateUuid } from '../../../../base/common/uuid.js';
15
import { localize } from '../../../../nls.js';
16
import { ByteSize, IFileService, IFileStat } from '../../../../platform/files/common/files.js';
17
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
18
import { INotificationService } from '../../../../platform/notification/common/notification.js';
19
import { DefaultQuickAccessFilterValue, IQuickAccessProvider, IQuickAccessProviderRunOptions } from '../../../../platform/quickinput/common/quickAccess.js';
20
import { IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
21
import { IEditorService } from '../../../services/editor/common/editorService.js';
22
import { IViewsService } from '../../../services/views/common/viewsService.js';
23
import { IChatWidgetService } from '../../chat/browser/chat.js';
24
import { IChatAttachmentResolveService } from '../../chat/browser/attachments/chatAttachmentResolveService.js';
25
import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js';
26
import { IMcpResource, IMcpResourceTemplate, IMcpServer, IMcpService, isMcpResourceTemplate, McpCapability, McpConnectionState, McpResourceURI } from '../common/mcpTypes.js';
27
import { McpIcons } from '../common/mcpIcons.js';
28
import { IUriTemplateVariable } from '../common/uriTemplate.js';
29
import { openPanelChatAndGetWidget } from './openPanelChatAndGetWidget.js';
30
import { LinkedList } from '../../../../base/common/linkedList.js';
31
import { ChatContextPickAttachment } from '../../chat/browser/attachments/chatContextPickService.js';
32
import { asArray } from '../../../../base/common/arrays.js';
33
34
export class McpResourcePickHelper extends Disposable {
35
private _resources = observableValue<{ picks: Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>; isBusy: boolean }>(this, { picks: new Map(), isBusy: true });
36
private _pickItemsStack: LinkedList<{ server: IMcpServer; resources: (IMcpResource | IMcpResourceTemplate)[] }> = new LinkedList();
37
private _inDirectory = observableValue<undefined | { server: IMcpServer; resources: (IMcpResource | IMcpResourceTemplate)[] }>(this, undefined);
38
public static sep(server: IMcpServer): IQuickPickSeparator {
39
return {
40
id: server.definition.id,
41
type: 'separator',
42
label: server.definition.label,
43
};
44
}
45
46
public addCurrentMCPQuickPickItemLevel(server: IMcpServer, resources: (IMcpResource | IMcpResourceTemplate)[]): void {
47
let isValidPush: boolean = false;
48
isValidPush = this._pickItemsStack.isEmpty();
49
if (!isValidPush) {
50
const stackedItem = this._pickItemsStack.peek();
51
if (stackedItem?.server === server && stackedItem.resources === resources) {
52
isValidPush = false;
53
} else {
54
isValidPush = true;
55
}
56
}
57
if (isValidPush) {
58
this._pickItemsStack.push({ server, resources });
59
}
60
61
}
62
63
public navigateBack(): boolean {
64
const items = this._pickItemsStack.pop();
65
if (items) {
66
this._inDirectory.set({ server: items.server, resources: items.resources }, undefined);
67
return true;
68
} else {
69
return false;
70
}
71
}
72
73
public static item(resource: IMcpResource | IMcpResourceTemplate): IQuickPickItem {
74
const iconPath = resource.icons.getUrl(22);
75
if (isMcpResourceTemplate(resource)) {
76
return {
77
id: resource.template.template,
78
label: resource.title || resource.name,
79
description: resource.description,
80
detail: localize('mcp.resource.template', 'Resource template: {0}', resource.template.template),
81
iconPath,
82
};
83
}
84
85
return {
86
id: resource.uri.toString(),
87
label: resource.title || resource.name,
88
description: resource.description,
89
detail: resource.mcpUri + (resource.sizeInBytes !== undefined ? ' (' + ByteSize.formatSize(resource.sizeInBytes) + ')' : ''),
90
iconPath,
91
};
92
}
93
94
public hasServersWithResources = derived(reader => {
95
let enabled = false;
96
for (const server of this._mcpService.servers.read(reader)) {
97
const cap = server.capabilities.read(undefined);
98
if (cap === undefined) {
99
enabled = true; // until we know more
100
} else if (cap & McpCapability.Resources) {
101
enabled = true;
102
break;
103
}
104
}
105
106
return enabled;
107
});
108
109
public explicitServers?: IMcpServer[];
110
111
constructor(
112
@IMcpService private readonly _mcpService: IMcpService,
113
@IFileService private readonly _fileService: IFileService,
114
@IQuickInputService private readonly _quickInputService: IQuickInputService,
115
@INotificationService private readonly _notificationService: INotificationService,
116
@IChatAttachmentResolveService private readonly _chatAttachmentResolveService: IChatAttachmentResolveService
117
) {
118
super();
119
}
120
121
/**
122
* Navigate to a resource if it's a directory.
123
* Returns true if the resource is a directory with children (navigation succeeded).
124
* Returns false if the resource is a leaf file (no navigation).
125
* When returning true, statefully updates the picker state to display directory contents.
126
*/
127
public async navigate(resource: IMcpResource | IMcpResourceTemplate, server: IMcpServer): Promise<boolean> {
128
if (isMcpResourceTemplate(resource)) {
129
return false;
130
}
131
132
const uri = resource.uri;
133
let stat: IFileStat | undefined = undefined;
134
try {
135
stat = await this._fileService.resolve(uri, { resolveMetadata: false });
136
} catch (e) {
137
return false;
138
}
139
140
if (stat && this._isDirectoryResource(resource) && (stat.children?.length ?? 0) > 0) {
141
// Save current state to stack before navigating
142
const currentResources = this._resources.get().picks.get(server);
143
if (currentResources) {
144
this.addCurrentMCPQuickPickItemLevel(server, currentResources);
145
}
146
147
// Convert all the children to IMcpResource objects
148
const childResources: IMcpResource[] = stat.children!.map(child => {
149
const mcpUri = McpResourceURI.fromServer(server.definition, child.resource.toString());
150
return {
151
uri: mcpUri,
152
mcpUri: child.resource.path,
153
name: child.name,
154
title: child.name,
155
description: resource.description,
156
mimeType: undefined,
157
sizeInBytes: child.size,
158
icons: McpIcons.fromParsed(undefined)
159
};
160
});
161
this._inDirectory.set({ server, resources: childResources }, undefined);
162
return true;
163
}
164
return false;
165
}
166
167
public toAttachment(resource: IMcpResource | IMcpResourceTemplate, server: IMcpServer): Promise<ChatContextPickAttachment> | 'noop' {
168
const noop = 'noop';
169
if (this._isDirectoryResource(resource)) {
170
//Check if directory
171
this.checkIfDirectoryAndPopulate(resource, server);
172
return noop;
173
}
174
if (isMcpResourceTemplate(resource)) {
175
return this._resourceTemplateToAttachment(resource).then(val => val || noop);
176
} else {
177
return this._resourceToAttachment(resource).then(val => val || noop);
178
}
179
}
180
181
public async checkIfDirectoryAndPopulate(resource: IMcpResource | IMcpResourceTemplate, server: IMcpServer): Promise<boolean> {
182
try {
183
return !await this.navigate(resource, server);
184
} catch (error) {
185
return false;
186
}
187
}
188
189
public async toURI(resource: IMcpResource | IMcpResourceTemplate): Promise<URI | undefined> {
190
if (isMcpResourceTemplate(resource)) {
191
const maybeUri = await this._resourceTemplateToURI(resource);
192
return maybeUri && await this._verifyUriIfNeeded(maybeUri);
193
} else {
194
return resource.uri;
195
}
196
}
197
198
public checkIfNestedResources = () => !this._pickItemsStack.isEmpty();
199
200
private async _resourceToAttachment(resource: { uri: URI; name: string; mimeType?: string }): Promise<IChatRequestVariableEntry | undefined> {
201
const asImage = await this._chatAttachmentResolveService.resolveImageEditorAttachContext(resource.uri, undefined, resource.mimeType);
202
if (asImage) {
203
return asImage;
204
}
205
206
return {
207
id: resource.uri.toString(),
208
kind: 'file',
209
name: resource.name,
210
value: resource.uri,
211
};
212
}
213
214
private async _resourceTemplateToAttachment(rt: IMcpResourceTemplate) {
215
const maybeUri = await this._resourceTemplateToURI(rt);
216
const uri = maybeUri && await this._verifyUriIfNeeded(maybeUri);
217
return uri && this._resourceToAttachment({
218
uri,
219
name: rt.name,
220
mimeType: rt.mimeType,
221
});
222
223
}
224
225
private async _verifyUriIfNeeded({ uri, needsVerification }: { uri: URI; needsVerification: boolean }): Promise<URI | undefined> {
226
if (!needsVerification) {
227
return uri;
228
}
229
230
const exists = await this._fileService.exists(uri);
231
if (exists) {
232
return uri;
233
}
234
235
this._notificationService.warn(localize('mcp.resource.template.notFound', "The resource {0} was not found.", McpResourceURI.toServer(uri).resourceURL.toString()));
236
return undefined;
237
}
238
239
private async _resourceTemplateToURI(rt: IMcpResourceTemplate) {
240
const todo = rt.template.components.flatMap(c => typeof c === 'object' ? c.variables : []);
241
242
const quickInput = this._quickInputService.createQuickPick();
243
const cts = new CancellationTokenSource();
244
245
const vars: Record<string, string | string[]> = {};
246
quickInput.totalSteps = todo.length;
247
quickInput.ignoreFocusOut = true;
248
let needsVerification = false;
249
250
try {
251
for (let i = 0; i < todo.length; i++) {
252
const variable = todo[i];
253
const resolved = await this._promptForTemplateValue(quickInput, variable, vars, rt);
254
if (resolved === undefined) {
255
return undefined;
256
}
257
// mark the URI as needing verification if any part was not a completion pick
258
needsVerification ||= !resolved.completed;
259
vars[todo[i].name] = variable.repeatable ? resolved.value.split('/') : resolved.value;
260
}
261
return { uri: rt.resolveURI(vars), needsVerification };
262
} finally {
263
cts.dispose(true);
264
quickInput.dispose();
265
}
266
}
267
268
private _promptForTemplateValue(input: IQuickPick<IQuickPickItem>, variable: IUriTemplateVariable, variablesSoFar: Record<string, string | string[]>, rt: IMcpResourceTemplate): Promise<{ value: string; completed: boolean } | undefined> {
269
const store = new DisposableStore();
270
const completions = new Map<string, Promise<string[]>>([]);
271
272
const variablesWithPlaceholders = { ...variablesSoFar };
273
for (const variable of rt.template.components.flatMap(c => typeof c === 'object' ? c.variables : [])) {
274
if (!variablesWithPlaceholders.hasOwnProperty(variable.name)) {
275
variablesWithPlaceholders[variable.name] = `$${variable.name.toUpperCase()}`;
276
}
277
}
278
279
let placeholder = localize('mcp.resource.template.placeholder', "Value for ${0} in {1}", variable.name.toUpperCase(), rt.template.resolve(variablesWithPlaceholders).replaceAll('%24', '$'));
280
if (variable.optional) {
281
placeholder += ' (' + localize('mcp.resource.template.optional', "Optional") + ')';
282
}
283
284
input.placeholder = placeholder;
285
input.value = '';
286
input.items = [];
287
input.show();
288
289
const currentID = generateUuid();
290
const setItems = (value: string, completed: string[] = []) => {
291
const items = completed.filter(c => c !== value).map(c => ({ id: c, label: c }));
292
if (value) {
293
items.unshift({ id: currentID, label: value });
294
} else if (variable.optional) {
295
items.unshift({ id: currentID, label: localize('mcp.resource.template.empty', "<Empty>") });
296
}
297
298
input.items = items;
299
};
300
301
let changeCancellation = new CancellationTokenSource();
302
store.add(toDisposable(() => changeCancellation.dispose(true)));
303
304
const getCompletionItems = () => {
305
const inputValue = input.value;
306
let promise = completions.get(inputValue);
307
if (!promise) {
308
promise = rt.complete(variable.name, inputValue, variablesSoFar, changeCancellation.token);
309
completions.set(inputValue, promise);
310
}
311
312
promise.then(values => {
313
if (!changeCancellation.token.isCancellationRequested) {
314
setItems(inputValue, values);
315
}
316
}).catch(() => {
317
completions.delete(inputValue);
318
}).finally(() => {
319
if (!changeCancellation.token.isCancellationRequested) {
320
input.busy = false;
321
}
322
});
323
};
324
325
const getCompletionItemsScheduler = store.add(new RunOnceScheduler(getCompletionItems, 300));
326
327
return new Promise<{ value: string; completed: boolean } | undefined>(resolve => {
328
store.add(input.onDidHide(() => resolve(undefined)));
329
store.add(input.onDidAccept(() => {
330
const item = input.selectedItems[0];
331
if (item.id === currentID) {
332
resolve({ value: input.value, completed: false });
333
} else if (variable.explodable && item.label.endsWith('/') && item.label !== input.value) {
334
// if navigating in a path structure, picking a `/` should let the user pick in a subdirectory
335
input.value = item.label;
336
} else {
337
resolve({ value: item.label, completed: true });
338
}
339
}));
340
store.add(input.onDidChangeValue(value => {
341
input.busy = true;
342
changeCancellation.dispose(true);
343
changeCancellation = new CancellationTokenSource();
344
getCompletionItemsScheduler.cancel();
345
setItems(value);
346
347
if (completions.has(input.value)) {
348
getCompletionItems();
349
} else {
350
getCompletionItemsScheduler.schedule();
351
}
352
}));
353
354
getCompletionItems();
355
}).finally(() => store.dispose());
356
}
357
358
private _isDirectoryResource(resource: IMcpResource | IMcpResourceTemplate): boolean {
359
360
if (resource.mimeType && resource.mimeType === 'inode/directory') {
361
return true;
362
} else if (isMcpResourceTemplate(resource)) {
363
return resource.template.template.endsWith('/');
364
} else {
365
return resource.uri.path.endsWith('/');
366
}
367
}
368
369
public getPicks(token?: CancellationToken): IObservable<{ picks: Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>; isBusy: boolean }> {
370
const cts = new CancellationTokenSource(token);
371
let isBusyLoadingPicks = true;
372
this._register(toDisposable(() => cts.dispose(true)));
373
// We try to show everything in-sequence to avoid flickering (#250411) as long as
374
// it loads within 5 seconds. Otherwise we just show things as the load in parallel.
375
let showInSequence = true;
376
this._register(disposableTimeout(() => {
377
showInSequence = false;
378
publish();
379
}, 5_000));
380
381
const publish = () => {
382
const output = new Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>();
383
for (const [server, rec] of servers) {
384
const r: (IMcpResourceTemplate | IMcpResource)[] = [];
385
output.set(server, r);
386
if (rec.templates.isResolved) {
387
r.push(...rec.templates.value!);
388
} else if (showInSequence) {
389
break;
390
}
391
392
r.push(...rec.resourcesSoFar);
393
if (!rec.resources.isSettled && showInSequence) {
394
break;
395
}
396
}
397
this._resources.set({ picks: output, isBusy: isBusyLoadingPicks }, undefined);
398
};
399
400
type Rec = { templates: DeferredPromise<IMcpResourceTemplate[]>; resourcesSoFar: IMcpResource[]; resources: DeferredPromise<unknown> };
401
402
const servers = new Map<IMcpServer, Rec>();
403
// Enumerate servers and start servers that need to be started to get capabilities
404
Promise.all((this.explicitServers || this._mcpService.servers.get()).map(async server => {
405
let cap = server.capabilities.get();
406
const rec: Rec = {
407
templates: new DeferredPromise(),
408
resourcesSoFar: [],
409
resources: new DeferredPromise(),
410
};
411
servers.set(server, rec); // always add it to retain order
412
413
if (cap === undefined) {
414
cap = await new Promise(resolve => {
415
server.start().then(state => {
416
if (state.state === McpConnectionState.Kind.Error || state.state === McpConnectionState.Kind.Stopped) {
417
resolve(undefined);
418
}
419
});
420
this._register(cts.token.onCancellationRequested(() => resolve(undefined)));
421
this._register(autorun(reader => {
422
const cap2 = server.capabilities.read(reader);
423
if (cap2 !== undefined) {
424
resolve(cap2);
425
}
426
}));
427
});
428
}
429
430
if (cap && (cap & McpCapability.Resources)) {
431
await Promise.all([
432
rec.templates.settleWith(server.resourceTemplates(cts.token).catch(() => [])).finally(publish),
433
rec.resources.settleWith((async () => {
434
for await (const page of server.resources(cts.token)) {
435
rec.resourcesSoFar = rec.resourcesSoFar.concat(page);
436
publish();
437
}
438
})())
439
]);
440
} else {
441
rec.templates.complete([]);
442
rec.resources.complete([]);
443
}
444
})).finally(() => {
445
isBusyLoadingPicks = false;
446
publish();
447
});
448
449
// Use derived to compute the appropriate resource map based on directory navigation state
450
return derived(this, reader => {
451
const directoryResource = this._inDirectory.read(reader);
452
return directoryResource
453
? { picks: new Map([[directoryResource.server, directoryResource.resources]]), isBusy: false }
454
: this._resources.read(reader);
455
});
456
}
457
}
458
459
export abstract class AbstractMcpResourceAccessPick {
460
constructor(
461
private readonly _scopeTo: IMcpServer | undefined,
462
@IInstantiationService private readonly _instantiationService: IInstantiationService,
463
@IEditorService private readonly _editorService: IEditorService,
464
@IChatWidgetService protected readonly _chatWidgetService: IChatWidgetService,
465
@IViewsService private readonly _viewsService: IViewsService,
466
) {
467
}
468
469
protected applyToPick(picker: IQuickPick<IQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions) {
470
picker.canAcceptInBackground = true;
471
picker.busy = true;
472
picker.keepScrollPosition = true;
473
const store = new DisposableStore();
474
const goBackId = '_goback_';
475
476
type ResourceQuickPickItem = IQuickPickItem & { resource: IMcpResource | IMcpResourceTemplate; server: IMcpServer };
477
478
const attachButton = localize('mcp.quickaccess.attach', "Attach to chat");
479
480
const helper = store.add(this._instantiationService.createInstance(McpResourcePickHelper));
481
if (this._scopeTo) {
482
helper.explicitServers = [this._scopeTo];
483
}
484
const picksObservable = helper.getPicks(token);
485
store.add(autorun(reader => {
486
const pickItems = picksObservable.read(reader);
487
const isBusy = pickItems.isBusy;
488
const items: (ResourceQuickPickItem | IQuickPickSeparator | IQuickPickItem)[] = [];
489
for (const [server, resources] of pickItems.picks) {
490
items.push(McpResourcePickHelper.sep(server));
491
for (const resource of resources) {
492
const pickItem = McpResourcePickHelper.item(resource);
493
pickItem.buttons = [{ iconClass: ThemeIcon.asClassName(Codicon.attach), tooltip: attachButton }];
494
items.push({ ...pickItem, resource, server });
495
}
496
}
497
if (helper.checkIfNestedResources()) {
498
// Add go back item
499
const goBackItem: IQuickPickItem = {
500
id: goBackId,
501
label: localize('goBack', 'Go back ↩'),
502
alwaysShow: true
503
};
504
items.push(goBackItem);
505
}
506
picker.items = items;
507
picker.busy = isBusy;
508
}));
509
510
store.add(picker.onDidTriggerItemButton(event => {
511
if (event.button.tooltip === attachButton) {
512
picker.busy = true;
513
const resourceItem = event.item as ResourceQuickPickItem;
514
const attachment = helper.toAttachment(resourceItem.resource, resourceItem.server);
515
if (attachment instanceof Promise) {
516
attachment.then(async a => {
517
if (a !== 'noop') {
518
const widget = await openPanelChatAndGetWidget(this._viewsService, this._chatWidgetService);
519
widget?.attachmentModel.addContext(...asArray(a));
520
}
521
picker.hide();
522
});
523
}
524
}
525
}));
526
527
store.add(picker.onDidHide(() => {
528
helper.dispose();
529
}));
530
531
store.add(picker.onDidAccept(async event => {
532
try {
533
picker.busy = true;
534
const [item] = picker.selectedItems;
535
536
// Check if go back item was selected
537
if (item.id === goBackId) {
538
helper.navigateBack();
539
picker.busy = false;
540
return;
541
}
542
543
const resourceItem = item as ResourceQuickPickItem;
544
const resource = resourceItem.resource;
545
// Try to navigate into the resource if it's a directory
546
const isNested = await helper.navigate(resource, resourceItem.server);
547
if (!isNested) {
548
const uri = await helper.toURI(resource);
549
if (uri) {
550
picker.hide();
551
this._editorService.openEditor({ resource: uri, options: { preserveFocus: event.inBackground } });
552
}
553
}
554
} finally {
555
picker.busy = false;
556
}
557
}));
558
return store;
559
}
560
}
561
562
export class McpResourceQuickPick extends AbstractMcpResourceAccessPick {
563
constructor(
564
scopeTo: IMcpServer | undefined,
565
@IInstantiationService instantiationService: IInstantiationService,
566
@IEditorService editorService: IEditorService,
567
@IChatWidgetService chatWidgetService: IChatWidgetService,
568
@IViewsService viewsService: IViewsService,
569
@IQuickInputService private readonly _quickInputService: IQuickInputService,
570
) {
571
super(scopeTo, instantiationService, editorService, chatWidgetService, viewsService);
572
}
573
574
public async pick(token = CancellationToken.None) {
575
const store = new DisposableStore();
576
const qp = store.add(this._quickInputService.createQuickPick({ useSeparators: true }));
577
qp.placeholder = localize('mcp.quickaccess.placeholder', "Search for resources");
578
store.add(this.applyToPick(qp, token));
579
store.add(qp.onDidHide(() => store.dispose()));
580
qp.show();
581
await Event.toPromise(qp.onDidHide);
582
}
583
}
584
585
export class McpResourceQuickAccess extends AbstractMcpResourceAccessPick implements IQuickAccessProvider {
586
public static readonly PREFIX = 'mcpr ';
587
588
defaultFilterValue = DefaultQuickAccessFilterValue.LAST;
589
590
constructor(
591
@IInstantiationService instantiationService: IInstantiationService,
592
@IEditorService editorService: IEditorService,
593
@IChatWidgetService chatWidgetService: IChatWidgetService,
594
@IViewsService viewsService: IViewsService
595
) {
596
super(undefined, instantiationService, editorService, chatWidgetService, viewsService);
597
}
598
599
provide(picker: IQuickPick<IQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable {
600
return this.applyToPick(picker, token, runOptions);
601
}
602
}
603
604