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
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 { 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 } from '../../../../base/common/lifecycle.js';
11
import { autorun, derived } 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 } 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/chatAttachmentResolveService.js';
25
import { IChatRequestVariableEntry } from '../../chat/common/chatVariableEntries.js';
26
import { IMcpResource, IMcpResourceTemplate, IMcpServer, IMcpService, isMcpResourceTemplate, McpCapability, McpConnectionState, McpResourceURI } from '../common/mcpTypes.js';
27
import { IUriTemplateVariable } from '../common/uriTemplate.js';
28
import { openPanelChatAndGetWidget } from './openPanelChatAndGetWidget.js';
29
30
export class McpResourcePickHelper {
31
public static sep(server: IMcpServer): IQuickPickSeparator {
32
return {
33
id: server.definition.id,
34
type: 'separator',
35
label: server.definition.label,
36
};
37
}
38
39
public static item(resource: IMcpResource | IMcpResourceTemplate): IQuickPickItem {
40
if (isMcpResourceTemplate(resource)) {
41
return {
42
id: resource.template.template,
43
label: resource.title || resource.name,
44
description: resource.description,
45
detail: localize('mcp.resource.template', 'Resource template: {0}', resource.template.template),
46
};
47
}
48
49
return {
50
id: resource.uri.toString(),
51
label: resource.title || resource.name,
52
description: resource.description,
53
detail: resource.mcpUri + (resource.sizeInBytes !== undefined ? ' (' + ByteSize.formatSize(resource.sizeInBytes) + ')' : ''),
54
};
55
}
56
57
public hasServersWithResources = derived(reader => {
58
let enabled = false;
59
for (const server of this._mcpService.servers.read(reader)) {
60
const cap = server.capabilities.get();
61
if (cap === undefined) {
62
enabled = true; // until we know more
63
} else if (cap & McpCapability.Resources) {
64
enabled = true;
65
break;
66
}
67
}
68
69
return enabled;
70
});
71
72
public explicitServers?: IMcpServer[];
73
74
constructor(
75
@IMcpService private readonly _mcpService: IMcpService,
76
@IFileService private readonly _fileService: IFileService,
77
@IQuickInputService private readonly _quickInputService: IQuickInputService,
78
@INotificationService private readonly _notificationService: INotificationService,
79
@IChatAttachmentResolveService private readonly _chatAttachmentResolveService: IChatAttachmentResolveService
80
) { }
81
82
public async toAttachment(resource: IMcpResource | IMcpResourceTemplate): Promise<IChatRequestVariableEntry | undefined> {
83
if (isMcpResourceTemplate(resource)) {
84
return this._resourceTemplateToAttachment(resource);
85
} else {
86
return this._resourceToAttachment(resource);
87
}
88
}
89
90
public async toURI(resource: IMcpResource | IMcpResourceTemplate): Promise<URI | undefined> {
91
if (isMcpResourceTemplate(resource)) {
92
const maybeUri = await this._resourceTemplateToURI(resource);
93
return maybeUri && await this._verifyUriIfNeeded(maybeUri);
94
} else {
95
return resource.uri;
96
}
97
}
98
99
private async _resourceToAttachment(resource: { uri: URI; name: string; mimeType?: string }): Promise<IChatRequestVariableEntry | undefined> {
100
const asImage = await this._chatAttachmentResolveService.resolveImageEditorAttachContext(resource.uri, undefined, resource.mimeType);
101
if (asImage) {
102
return asImage;
103
}
104
105
return {
106
id: resource.uri.toString(),
107
kind: 'file',
108
name: resource.name,
109
value: resource.uri,
110
};
111
}
112
113
private async _resourceTemplateToAttachment(rt: IMcpResourceTemplate) {
114
const maybeUri = await this._resourceTemplateToURI(rt);
115
const uri = maybeUri && await this._verifyUriIfNeeded(maybeUri);
116
return uri && this._resourceToAttachment({
117
uri,
118
name: rt.name,
119
mimeType: rt.mimeType,
120
});
121
}
122
123
private async _verifyUriIfNeeded({ uri, needsVerification }: { uri: URI; needsVerification: boolean }): Promise<URI | undefined> {
124
if (!needsVerification) {
125
return uri;
126
}
127
128
const exists = await this._fileService.exists(uri);
129
if (exists) {
130
return uri;
131
}
132
133
this._notificationService.warn(localize('mcp.resource.template.notFound', "The resource {0} was not found.", McpResourceURI.toServer(uri).resourceURL.toString()));
134
return undefined;
135
}
136
137
private async _resourceTemplateToURI(rt: IMcpResourceTemplate) {
138
const todo = rt.template.components.flatMap(c => typeof c === 'object' ? c.variables : []);
139
140
const quickInput = this._quickInputService.createQuickPick();
141
const cts = new CancellationTokenSource();
142
143
const vars: Record<string, string | string[]> = {};
144
quickInput.totalSteps = todo.length;
145
quickInput.ignoreFocusOut = true;
146
let needsVerification = false;
147
148
try {
149
for (let i = 0; i < todo.length; i++) {
150
const variable = todo[i];
151
const resolved = await this._promptForTemplateValue(quickInput, variable, vars, rt);
152
if (resolved === undefined) {
153
return undefined;
154
}
155
// mark the URI as needing verification if any part was not a completion pick
156
needsVerification ||= !resolved.completed;
157
vars[todo[i].name] = variable.repeatable ? resolved.value.split('/') : resolved.value;
158
}
159
return { uri: rt.resolveURI(vars), needsVerification };
160
} finally {
161
cts.dispose(true);
162
quickInput.dispose();
163
}
164
}
165
166
private _promptForTemplateValue(input: IQuickPick<IQuickPickItem>, variable: IUriTemplateVariable, variablesSoFar: Record<string, string | string[]>, rt: IMcpResourceTemplate): Promise<{ value: string; completed: boolean } | undefined> {
167
const store = new DisposableStore();
168
const completions = new Map<string, Promise<string[]>>([]);
169
170
const variablesWithPlaceholders = { ...variablesSoFar };
171
for (const variable of rt.template.components.flatMap(c => typeof c === 'object' ? c.variables : [])) {
172
if (!variablesWithPlaceholders.hasOwnProperty(variable.name)) {
173
variablesWithPlaceholders[variable.name] = `$${variable.name.toUpperCase()}`;
174
}
175
}
176
177
let placeholder = localize('mcp.resource.template.placeholder', "Value for ${0} in {1}", variable.name.toUpperCase(), rt.template.resolve(variablesWithPlaceholders).replaceAll('%24', '$'));
178
if (variable.optional) {
179
placeholder += ' (' + localize('mcp.resource.template.optional', "Optional") + ')';
180
}
181
182
input.placeholder = placeholder;
183
input.value = '';
184
input.items = [];
185
input.show();
186
187
const currentID = generateUuid();
188
const setItems = (value: string, completed: string[] = []) => {
189
const items = completed.filter(c => c !== value).map(c => ({ id: c, label: c }));
190
if (value) {
191
items.unshift({ id: currentID, label: value });
192
} else if (variable.optional) {
193
items.unshift({ id: currentID, label: localize('mcp.resource.template.empty', "<Empty>") });
194
}
195
196
input.items = items;
197
};
198
199
let changeCancellation = store.add(new CancellationTokenSource());
200
const getCompletionItems = () => {
201
const inputValue = input.value;
202
let promise = completions.get(inputValue);
203
if (!promise) {
204
promise = rt.complete(variable.name, inputValue, variablesSoFar, changeCancellation.token);
205
completions.set(inputValue, promise);
206
}
207
208
promise.then(values => {
209
if (!changeCancellation.token.isCancellationRequested) {
210
setItems(inputValue, values);
211
}
212
}).catch(() => {
213
completions.delete(inputValue);
214
}).finally(() => {
215
if (!changeCancellation.token.isCancellationRequested) {
216
input.busy = false;
217
}
218
});
219
};
220
221
const getCompletionItemsScheduler = store.add(new RunOnceScheduler(getCompletionItems, 300));
222
223
return new Promise<{ value: string; completed: boolean } | undefined>(resolve => {
224
store.add(input.onDidHide(() => resolve(undefined)));
225
store.add(input.onDidAccept(() => {
226
const item = input.selectedItems[0];
227
if (item.id === currentID) {
228
resolve({ value: input.value, completed: false });
229
} else if (variable.explodable && item.label.endsWith('/') && item.label !== input.value) {
230
// if navigating in a path structure, picking a `/` should let the user pick in a subdirectory
231
input.value = item.label;
232
} else {
233
resolve({ value: item.label, completed: true });
234
}
235
}));
236
store.add(input.onDidChangeValue(value => {
237
input.busy = true;
238
changeCancellation.dispose(true);
239
store.delete(changeCancellation);
240
changeCancellation = store.add(new CancellationTokenSource());
241
getCompletionItemsScheduler.cancel();
242
setItems(value);
243
244
if (completions.has(input.value)) {
245
getCompletionItems();
246
} else {
247
getCompletionItemsScheduler.schedule();
248
}
249
}));
250
251
getCompletionItems();
252
}).finally(() => store.dispose());
253
}
254
255
public getPicks(onChange: (value: Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>) => void, token?: CancellationToken) {
256
const cts = new CancellationTokenSource(token);
257
const store = new DisposableStore();
258
store.add(toDisposable(() => cts.dispose(true)));
259
260
// We try to show everything in-sequence to avoid flickering (#250411) as long as
261
// it loads within 5 seconds. Otherwise we just show things as the load in parallel.
262
let showInSequence = true;
263
store.add(disposableTimeout(() => {
264
showInSequence = false;
265
publish();
266
}, 5_000));
267
268
const publish = () => {
269
const output = new Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>();
270
for (const [server, rec] of servers) {
271
const r: (IMcpResourceTemplate | IMcpResource)[] = [];
272
output.set(server, r);
273
if (rec.templates.isResolved) {
274
r.push(...rec.templates.value!);
275
} else if (showInSequence) {
276
break;
277
}
278
279
r.push(...rec.resourcesSoFar);
280
if (!rec.resources.isSettled && showInSequence) {
281
break;
282
}
283
}
284
onChange(output);
285
};
286
287
type Rec = { templates: DeferredPromise<IMcpResourceTemplate[]>; resourcesSoFar: IMcpResource[]; resources: DeferredPromise<unknown> };
288
289
const servers = new Map<IMcpServer, Rec>();
290
// Enumerate servers and start servers that need to be started to get capabilities
291
return Promise.all((this.explicitServers || this._mcpService.servers.get()).map(async server => {
292
let cap = server.capabilities.get();
293
const rec: Rec = {
294
templates: new DeferredPromise(),
295
resourcesSoFar: [],
296
resources: new DeferredPromise(),
297
};
298
servers.set(server, rec); // always add it to retain order
299
300
if (cap === undefined) {
301
cap = await new Promise(resolve => {
302
server.start().then(state => {
303
if (state.state === McpConnectionState.Kind.Error || state.state === McpConnectionState.Kind.Stopped) {
304
resolve(undefined);
305
}
306
});
307
store.add(cts.token.onCancellationRequested(() => resolve(undefined)));
308
store.add(autorun(reader => {
309
const cap2 = server.capabilities.read(reader);
310
if (cap2 !== undefined) {
311
resolve(cap2);
312
}
313
}));
314
});
315
}
316
317
if (cap && (cap & McpCapability.Resources)) {
318
await Promise.all([
319
rec.templates.settleWith(server.resourceTemplates(cts.token).catch(() => [])).finally(publish),
320
rec.resources.settleWith((async () => {
321
for await (const page of server.resources(cts.token)) {
322
rec.resourcesSoFar = rec.resourcesSoFar.concat(page);
323
publish();
324
}
325
})())
326
]);
327
} else {
328
rec.templates.complete([]);
329
rec.resources.complete([]);
330
}
331
publish();
332
})).finally(() => {
333
store.dispose();
334
});
335
}
336
}
337
338
339
export abstract class AbstractMcpResourceAccessPick {
340
constructor(
341
private readonly _scopeTo: IMcpServer | undefined,
342
@IInstantiationService private readonly _instantiationService: IInstantiationService,
343
@IEditorService private readonly _editorService: IEditorService,
344
@IChatWidgetService protected readonly _chatWidgetService: IChatWidgetService,
345
@IViewsService private readonly _viewsService: IViewsService,
346
) { }
347
348
protected applyToPick(picker: IQuickPick<IQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions) {
349
picker.canAcceptInBackground = true;
350
picker.busy = true;
351
picker.keepScrollPosition = true;
352
353
type ResourceQuickPickItem = IQuickPickItem & { resource: IMcpResource | IMcpResourceTemplate };
354
355
const attachButton = localize('mcp.quickaccess.attach', "Attach to chat");
356
357
const helper = this._instantiationService.createInstance(McpResourcePickHelper);
358
if (this._scopeTo) {
359
helper.explicitServers = [this._scopeTo];
360
}
361
helper.getPicks(servers => {
362
const items: (ResourceQuickPickItem | IQuickPickSeparator)[] = [];
363
for (const [server, resources] of servers) {
364
items.push(McpResourcePickHelper.sep(server));
365
for (const resource of resources) {
366
const pickItem = McpResourcePickHelper.item(resource);
367
pickItem.buttons = [{ iconClass: ThemeIcon.asClassName(Codicon.attach), tooltip: attachButton }];
368
items.push({ ...pickItem, resource });
369
}
370
}
371
picker.items = items;
372
}, token).finally(() => {
373
picker.busy = false;
374
});
375
376
const store = new DisposableStore();
377
store.add(picker.onDidTriggerItemButton(event => {
378
if (event.button.tooltip === attachButton) {
379
picker.busy = true;
380
helper.toAttachment((event.item as ResourceQuickPickItem).resource).then(async a => {
381
if (a) {
382
const widget = await openPanelChatAndGetWidget(this._viewsService, this._chatWidgetService);
383
widget?.attachmentModel.addContext(a);
384
}
385
picker.hide();
386
});
387
}
388
}));
389
390
store.add(picker.onDidAccept(async event => {
391
if (!event.inBackground) {
392
picker.hide(); // hide picker unless we accept in background
393
}
394
395
if (runOptions?.handleAccept) {
396
runOptions.handleAccept?.(picker.activeItems[0], event.inBackground);
397
} else {
398
const [item] = picker.selectedItems;
399
const uri = await helper.toURI((item as ResourceQuickPickItem).resource);
400
if (uri) {
401
this._editorService.openEditor({ resource: uri, options: { preserveFocus: event.inBackground } });
402
}
403
}
404
}));
405
406
return store;
407
}
408
}
409
410
export class McpResourceQuickPick extends AbstractMcpResourceAccessPick {
411
constructor(
412
scopeTo: IMcpServer | undefined,
413
@IInstantiationService instantiationService: IInstantiationService,
414
@IEditorService editorService: IEditorService,
415
@IChatWidgetService chatWidgetService: IChatWidgetService,
416
@IViewsService viewsService: IViewsService,
417
@IQuickInputService private readonly _quickInputService: IQuickInputService,
418
) {
419
super(scopeTo, instantiationService, editorService, chatWidgetService, viewsService);
420
}
421
422
public async pick(token = CancellationToken.None) {
423
const store = new DisposableStore();
424
const qp = store.add(this._quickInputService.createQuickPick({ useSeparators: true }));
425
qp.placeholder = localize('mcp.quickaccess.placeholder', "Search for resources");
426
store.add(this.applyToPick(qp, token));
427
store.add(qp.onDidHide(() => store.dispose()));
428
qp.show();
429
await Event.toPromise(qp.onDidHide);
430
}
431
}
432
433
export class McpResourceQuickAccess extends AbstractMcpResourceAccessPick implements IQuickAccessProvider {
434
public static readonly PREFIX = 'mcpr ';
435
436
defaultFilterValue = DefaultQuickAccessFilterValue.LAST;
437
438
constructor(
439
@IInstantiationService instantiationService: IInstantiationService,
440
@IEditorService editorService: IEditorService,
441
@IChatWidgetService chatWidgetService: IChatWidgetService,
442
@IViewsService viewsService: IViewsService,
443
) {
444
super(undefined, instantiationService, editorService, chatWidgetService, viewsService);
445
}
446
447
provide(picker: IQuickPick<IQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable {
448
return this.applyToPick(picker, token, runOptions);
449
}
450
}
451
452