Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpResourceQuickAccess.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { DeferredPromise, disposableTimeout, RunOnceScheduler } from '../../../../base/common/async.js';6import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';7import { Codicon } from '../../../../base/common/codicons.js';8import { Event } from '../../../../base/common/event.js';9import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';10import { autorun, derived } from '../../../../base/common/observable.js';11import { ThemeIcon } from '../../../../base/common/themables.js';12import { URI } from '../../../../base/common/uri.js';13import { generateUuid } from '../../../../base/common/uuid.js';14import { localize } from '../../../../nls.js';15import { ByteSize, IFileService } from '../../../../platform/files/common/files.js';16import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';17import { INotificationService } from '../../../../platform/notification/common/notification.js';18import { DefaultQuickAccessFilterValue, IQuickAccessProvider, IQuickAccessProviderRunOptions } from '../../../../platform/quickinput/common/quickAccess.js';19import { IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';20import { IEditorService } from '../../../services/editor/common/editorService.js';21import { IViewsService } from '../../../services/views/common/viewsService.js';22import { IChatWidgetService } from '../../chat/browser/chat.js';23import { IChatAttachmentResolveService } from '../../chat/browser/chatAttachmentResolveService.js';24import { IChatRequestVariableEntry } from '../../chat/common/chatVariableEntries.js';25import { IMcpResource, IMcpResourceTemplate, IMcpServer, IMcpService, isMcpResourceTemplate, McpCapability, McpConnectionState, McpResourceURI } from '../common/mcpTypes.js';26import { IUriTemplateVariable } from '../common/uriTemplate.js';27import { openPanelChatAndGetWidget } from './openPanelChatAndGetWidget.js';2829export class McpResourcePickHelper {30public static sep(server: IMcpServer): IQuickPickSeparator {31return {32id: server.definition.id,33type: 'separator',34label: server.definition.label,35};36}3738public static item(resource: IMcpResource | IMcpResourceTemplate): IQuickPickItem {39if (isMcpResourceTemplate(resource)) {40return {41id: resource.template.template,42label: resource.title || resource.name,43description: resource.description,44detail: localize('mcp.resource.template', 'Resource template: {0}', resource.template.template),45};46}4748return {49id: resource.uri.toString(),50label: resource.title || resource.name,51description: resource.description,52detail: resource.mcpUri + (resource.sizeInBytes !== undefined ? ' (' + ByteSize.formatSize(resource.sizeInBytes) + ')' : ''),53};54}5556public hasServersWithResources = derived(reader => {57let enabled = false;58for (const server of this._mcpService.servers.read(reader)) {59const cap = server.capabilities.get();60if (cap === undefined) {61enabled = true; // until we know more62} else if (cap & McpCapability.Resources) {63enabled = true;64break;65}66}6768return enabled;69});7071public explicitServers?: IMcpServer[];7273constructor(74@IMcpService private readonly _mcpService: IMcpService,75@IFileService private readonly _fileService: IFileService,76@IQuickInputService private readonly _quickInputService: IQuickInputService,77@INotificationService private readonly _notificationService: INotificationService,78@IChatAttachmentResolveService private readonly _chatAttachmentResolveService: IChatAttachmentResolveService79) { }8081public async toAttachment(resource: IMcpResource | IMcpResourceTemplate): Promise<IChatRequestVariableEntry | undefined> {82if (isMcpResourceTemplate(resource)) {83return this._resourceTemplateToAttachment(resource);84} else {85return this._resourceToAttachment(resource);86}87}8889public async toURI(resource: IMcpResource | IMcpResourceTemplate): Promise<URI | undefined> {90if (isMcpResourceTemplate(resource)) {91const maybeUri = await this._resourceTemplateToURI(resource);92return maybeUri && await this._verifyUriIfNeeded(maybeUri);93} else {94return resource.uri;95}96}9798private async _resourceToAttachment(resource: { uri: URI; name: string; mimeType?: string }): Promise<IChatRequestVariableEntry | undefined> {99const asImage = await this._chatAttachmentResolveService.resolveImageEditorAttachContext(resource.uri, undefined, resource.mimeType);100if (asImage) {101return asImage;102}103104return {105id: resource.uri.toString(),106kind: 'file',107name: resource.name,108value: resource.uri,109};110}111112private async _resourceTemplateToAttachment(rt: IMcpResourceTemplate) {113const maybeUri = await this._resourceTemplateToURI(rt);114const uri = maybeUri && await this._verifyUriIfNeeded(maybeUri);115return uri && this._resourceToAttachment({116uri,117name: rt.name,118mimeType: rt.mimeType,119});120}121122private async _verifyUriIfNeeded({ uri, needsVerification }: { uri: URI; needsVerification: boolean }): Promise<URI | undefined> {123if (!needsVerification) {124return uri;125}126127const exists = await this._fileService.exists(uri);128if (exists) {129return uri;130}131132this._notificationService.warn(localize('mcp.resource.template.notFound', "The resource {0} was not found.", McpResourceURI.toServer(uri).resourceURL.toString()));133return undefined;134}135136private async _resourceTemplateToURI(rt: IMcpResourceTemplate) {137const todo = rt.template.components.flatMap(c => typeof c === 'object' ? c.variables : []);138139const quickInput = this._quickInputService.createQuickPick();140const cts = new CancellationTokenSource();141142const vars: Record<string, string | string[]> = {};143quickInput.totalSteps = todo.length;144quickInput.ignoreFocusOut = true;145let needsVerification = false;146147try {148for (let i = 0; i < todo.length; i++) {149const variable = todo[i];150const resolved = await this._promptForTemplateValue(quickInput, variable, vars, rt);151if (resolved === undefined) {152return undefined;153}154// mark the URI as needing verification if any part was not a completion pick155needsVerification ||= !resolved.completed;156vars[todo[i].name] = variable.repeatable ? resolved.value.split('/') : resolved.value;157}158return { uri: rt.resolveURI(vars), needsVerification };159} finally {160cts.dispose(true);161quickInput.dispose();162}163}164165private _promptForTemplateValue(input: IQuickPick<IQuickPickItem>, variable: IUriTemplateVariable, variablesSoFar: Record<string, string | string[]>, rt: IMcpResourceTemplate): Promise<{ value: string; completed: boolean } | undefined> {166const store = new DisposableStore();167const completions = new Map<string, Promise<string[]>>([]);168169const variablesWithPlaceholders = { ...variablesSoFar };170for (const variable of rt.template.components.flatMap(c => typeof c === 'object' ? c.variables : [])) {171if (!variablesWithPlaceholders.hasOwnProperty(variable.name)) {172variablesWithPlaceholders[variable.name] = `$${variable.name.toUpperCase()}`;173}174}175176let placeholder = localize('mcp.resource.template.placeholder', "Value for ${0} in {1}", variable.name.toUpperCase(), rt.template.resolve(variablesWithPlaceholders).replaceAll('%24', '$'));177if (variable.optional) {178placeholder += ' (' + localize('mcp.resource.template.optional', "Optional") + ')';179}180181input.placeholder = placeholder;182input.value = '';183input.items = [];184input.show();185186const currentID = generateUuid();187const setItems = (value: string, completed: string[] = []) => {188const items = completed.filter(c => c !== value).map(c => ({ id: c, label: c }));189if (value) {190items.unshift({ id: currentID, label: value });191} else if (variable.optional) {192items.unshift({ id: currentID, label: localize('mcp.resource.template.empty', "<Empty>") });193}194195input.items = items;196};197198let changeCancellation = store.add(new CancellationTokenSource());199const getCompletionItems = () => {200const inputValue = input.value;201let promise = completions.get(inputValue);202if (!promise) {203promise = rt.complete(variable.name, inputValue, variablesSoFar, changeCancellation.token);204completions.set(inputValue, promise);205}206207promise.then(values => {208if (!changeCancellation.token.isCancellationRequested) {209setItems(inputValue, values);210}211}).catch(() => {212completions.delete(inputValue);213}).finally(() => {214if (!changeCancellation.token.isCancellationRequested) {215input.busy = false;216}217});218};219220const getCompletionItemsScheduler = store.add(new RunOnceScheduler(getCompletionItems, 300));221222return new Promise<{ value: string; completed: boolean } | undefined>(resolve => {223store.add(input.onDidHide(() => resolve(undefined)));224store.add(input.onDidAccept(() => {225const item = input.selectedItems[0];226if (item.id === currentID) {227resolve({ value: input.value, completed: false });228} else if (variable.explodable && item.label.endsWith('/') && item.label !== input.value) {229// if navigating in a path structure, picking a `/` should let the user pick in a subdirectory230input.value = item.label;231} else {232resolve({ value: item.label, completed: true });233}234}));235store.add(input.onDidChangeValue(value => {236input.busy = true;237changeCancellation.dispose(true);238store.delete(changeCancellation);239changeCancellation = store.add(new CancellationTokenSource());240getCompletionItemsScheduler.cancel();241setItems(value);242243if (completions.has(input.value)) {244getCompletionItems();245} else {246getCompletionItemsScheduler.schedule();247}248}));249250getCompletionItems();251}).finally(() => store.dispose());252}253254public getPicks(onChange: (value: Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>) => void, token?: CancellationToken) {255const cts = new CancellationTokenSource(token);256const store = new DisposableStore();257store.add(toDisposable(() => cts.dispose(true)));258259// We try to show everything in-sequence to avoid flickering (#250411) as long as260// it loads within 5 seconds. Otherwise we just show things as the load in parallel.261let showInSequence = true;262store.add(disposableTimeout(() => {263showInSequence = false;264publish();265}, 5_000));266267const publish = () => {268const output = new Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>();269for (const [server, rec] of servers) {270const r: (IMcpResourceTemplate | IMcpResource)[] = [];271output.set(server, r);272if (rec.templates.isResolved) {273r.push(...rec.templates.value!);274} else if (showInSequence) {275break;276}277278r.push(...rec.resourcesSoFar);279if (!rec.resources.isSettled && showInSequence) {280break;281}282}283onChange(output);284};285286type Rec = { templates: DeferredPromise<IMcpResourceTemplate[]>; resourcesSoFar: IMcpResource[]; resources: DeferredPromise<unknown> };287288const servers = new Map<IMcpServer, Rec>();289// Enumerate servers and start servers that need to be started to get capabilities290return Promise.all((this.explicitServers || this._mcpService.servers.get()).map(async server => {291let cap = server.capabilities.get();292const rec: Rec = {293templates: new DeferredPromise(),294resourcesSoFar: [],295resources: new DeferredPromise(),296};297servers.set(server, rec); // always add it to retain order298299if (cap === undefined) {300cap = await new Promise(resolve => {301server.start().then(state => {302if (state.state === McpConnectionState.Kind.Error || state.state === McpConnectionState.Kind.Stopped) {303resolve(undefined);304}305});306store.add(cts.token.onCancellationRequested(() => resolve(undefined)));307store.add(autorun(reader => {308const cap2 = server.capabilities.read(reader);309if (cap2 !== undefined) {310resolve(cap2);311}312}));313});314}315316if (cap && (cap & McpCapability.Resources)) {317await Promise.all([318rec.templates.settleWith(server.resourceTemplates(cts.token).catch(() => [])).finally(publish),319rec.resources.settleWith((async () => {320for await (const page of server.resources(cts.token)) {321rec.resourcesSoFar = rec.resourcesSoFar.concat(page);322publish();323}324})())325]);326} else {327rec.templates.complete([]);328rec.resources.complete([]);329}330publish();331})).finally(() => {332store.dispose();333});334}335}336337338export abstract class AbstractMcpResourceAccessPick {339constructor(340private readonly _scopeTo: IMcpServer | undefined,341@IInstantiationService private readonly _instantiationService: IInstantiationService,342@IEditorService private readonly _editorService: IEditorService,343@IChatWidgetService protected readonly _chatWidgetService: IChatWidgetService,344@IViewsService private readonly _viewsService: IViewsService,345) { }346347protected applyToPick(picker: IQuickPick<IQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions) {348picker.canAcceptInBackground = true;349picker.busy = true;350picker.keepScrollPosition = true;351352type ResourceQuickPickItem = IQuickPickItem & { resource: IMcpResource | IMcpResourceTemplate };353354const attachButton = localize('mcp.quickaccess.attach', "Attach to chat");355356const helper = this._instantiationService.createInstance(McpResourcePickHelper);357if (this._scopeTo) {358helper.explicitServers = [this._scopeTo];359}360helper.getPicks(servers => {361const items: (ResourceQuickPickItem | IQuickPickSeparator)[] = [];362for (const [server, resources] of servers) {363items.push(McpResourcePickHelper.sep(server));364for (const resource of resources) {365const pickItem = McpResourcePickHelper.item(resource);366pickItem.buttons = [{ iconClass: ThemeIcon.asClassName(Codicon.attach), tooltip: attachButton }];367items.push({ ...pickItem, resource });368}369}370picker.items = items;371}, token).finally(() => {372picker.busy = false;373});374375const store = new DisposableStore();376store.add(picker.onDidTriggerItemButton(event => {377if (event.button.tooltip === attachButton) {378picker.busy = true;379helper.toAttachment((event.item as ResourceQuickPickItem).resource).then(async a => {380if (a) {381const widget = await openPanelChatAndGetWidget(this._viewsService, this._chatWidgetService);382widget?.attachmentModel.addContext(a);383}384picker.hide();385});386}387}));388389store.add(picker.onDidAccept(async event => {390if (!event.inBackground) {391picker.hide(); // hide picker unless we accept in background392}393394if (runOptions?.handleAccept) {395runOptions.handleAccept?.(picker.activeItems[0], event.inBackground);396} else {397const [item] = picker.selectedItems;398const uri = await helper.toURI((item as ResourceQuickPickItem).resource);399if (uri) {400this._editorService.openEditor({ resource: uri, options: { preserveFocus: event.inBackground } });401}402}403}));404405return store;406}407}408409export class McpResourceQuickPick extends AbstractMcpResourceAccessPick {410constructor(411scopeTo: IMcpServer | undefined,412@IInstantiationService instantiationService: IInstantiationService,413@IEditorService editorService: IEditorService,414@IChatWidgetService chatWidgetService: IChatWidgetService,415@IViewsService viewsService: IViewsService,416@IQuickInputService private readonly _quickInputService: IQuickInputService,417) {418super(scopeTo, instantiationService, editorService, chatWidgetService, viewsService);419}420421public async pick(token = CancellationToken.None) {422const store = new DisposableStore();423const qp = store.add(this._quickInputService.createQuickPick({ useSeparators: true }));424qp.placeholder = localize('mcp.quickaccess.placeholder', "Search for resources");425store.add(this.applyToPick(qp, token));426store.add(qp.onDidHide(() => store.dispose()));427qp.show();428await Event.toPromise(qp.onDidHide);429}430}431432export class McpResourceQuickAccess extends AbstractMcpResourceAccessPick implements IQuickAccessProvider {433public static readonly PREFIX = 'mcpr ';434435defaultFilterValue = DefaultQuickAccessFilterValue.LAST;436437constructor(438@IInstantiationService instantiationService: IInstantiationService,439@IEditorService editorService: IEditorService,440@IChatWidgetService chatWidgetService: IChatWidgetService,441@IViewsService viewsService: IViewsService,442) {443super(undefined, instantiationService, editorService, chatWidgetService, viewsService);444}445446provide(picker: IQuickPick<IQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable {447return this.applyToPick(picker, token, runOptions);448}449}450451452