Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpResourceQuickAccess.ts
5220 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, Disposable } from '../../../../base/common/lifecycle.js';10import { autorun, derived, observableValue, IObservable } 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, IFileStat } 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/attachments/chatAttachmentResolveService.js';24import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js';25import { IMcpResource, IMcpResourceTemplate, IMcpServer, IMcpService, isMcpResourceTemplate, McpCapability, McpConnectionState, McpResourceURI } from '../common/mcpTypes.js';26import { McpIcons } from '../common/mcpIcons.js';27import { IUriTemplateVariable } from '../common/uriTemplate.js';28import { openPanelChatAndGetWidget } from './openPanelChatAndGetWidget.js';29import { LinkedList } from '../../../../base/common/linkedList.js';30import { ChatContextPickAttachment } from '../../chat/browser/attachments/chatContextPickService.js';31import { asArray } from '../../../../base/common/arrays.js';3233export class McpResourcePickHelper extends Disposable {34private _resources = observableValue<{ picks: Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>; isBusy: boolean }>(this, { picks: new Map(), isBusy: true });35private _pickItemsStack: LinkedList<{ server: IMcpServer; resources: (IMcpResource | IMcpResourceTemplate)[] }> = new LinkedList();36private _inDirectory = observableValue<undefined | { server: IMcpServer; resources: (IMcpResource | IMcpResourceTemplate)[] }>(this, undefined);37public static sep(server: IMcpServer): IQuickPickSeparator {38return {39id: server.definition.id,40type: 'separator',41label: server.definition.label,42};43}4445public addCurrentMCPQuickPickItemLevel(server: IMcpServer, resources: (IMcpResource | IMcpResourceTemplate)[]): void {46let isValidPush: boolean = false;47isValidPush = this._pickItemsStack.isEmpty();48if (!isValidPush) {49const stackedItem = this._pickItemsStack.peek();50if (stackedItem?.server === server && stackedItem.resources === resources) {51isValidPush = false;52} else {53isValidPush = true;54}55}56if (isValidPush) {57this._pickItemsStack.push({ server, resources });58}5960}6162public navigateBack(): boolean {63const items = this._pickItemsStack.pop();64if (items) {65this._inDirectory.set({ server: items.server, resources: items.resources }, undefined);66return true;67} else {68return false;69}70}7172public static item(resource: IMcpResource | IMcpResourceTemplate): IQuickPickItem {73const iconPath = resource.icons.getUrl(22);74if (isMcpResourceTemplate(resource)) {75return {76id: resource.template.template,77label: resource.title || resource.name,78description: resource.description,79detail: localize('mcp.resource.template', 'Resource template: {0}', resource.template.template),80iconPath,81};82}8384return {85id: resource.uri.toString(),86label: resource.title || resource.name,87description: resource.description,88detail: resource.mcpUri + (resource.sizeInBytes !== undefined ? ' (' + ByteSize.formatSize(resource.sizeInBytes) + ')' : ''),89iconPath,90};91}9293public hasServersWithResources = derived(reader => {94let enabled = false;95for (const server of this._mcpService.servers.read(reader)) {96const cap = server.capabilities.read(undefined);97if (cap === undefined) {98enabled = true; // until we know more99} else if (cap & McpCapability.Resources) {100enabled = true;101break;102}103}104105return enabled;106});107108public explicitServers?: IMcpServer[];109110constructor(111@IMcpService private readonly _mcpService: IMcpService,112@IFileService private readonly _fileService: IFileService,113@IQuickInputService private readonly _quickInputService: IQuickInputService,114@INotificationService private readonly _notificationService: INotificationService,115@IChatAttachmentResolveService private readonly _chatAttachmentResolveService: IChatAttachmentResolveService116) {117super();118}119120/**121* Navigate to a resource if it's a directory.122* Returns true if the resource is a directory with children (navigation succeeded).123* Returns false if the resource is a leaf file (no navigation).124* When returning true, statefully updates the picker state to display directory contents.125*/126public async navigate(resource: IMcpResource | IMcpResourceTemplate, server: IMcpServer): Promise<boolean> {127if (isMcpResourceTemplate(resource)) {128return false;129}130131const uri = resource.uri;132let stat: IFileStat | undefined = undefined;133try {134stat = await this._fileService.resolve(uri, { resolveMetadata: false });135} catch (e) {136return false;137}138139if (stat && this._isDirectoryResource(resource) && (stat.children?.length ?? 0) > 0) {140// Save current state to stack before navigating141const currentResources = this._resources.get().picks.get(server);142if (currentResources) {143this.addCurrentMCPQuickPickItemLevel(server, currentResources);144}145146// Convert all the children to IMcpResource objects147const childResources: IMcpResource[] = stat.children!.map(child => {148const mcpUri = McpResourceURI.fromServer(server.definition, child.resource.toString());149return {150uri: mcpUri,151mcpUri: child.resource.path,152name: child.name,153title: child.name,154description: resource.description,155mimeType: undefined,156sizeInBytes: child.size,157icons: McpIcons.fromParsed(undefined)158};159});160this._inDirectory.set({ server, resources: childResources }, undefined);161return true;162}163return false;164}165166public toAttachment(resource: IMcpResource | IMcpResourceTemplate, server: IMcpServer): Promise<ChatContextPickAttachment> | 'noop' {167const noop = 'noop';168if (this._isDirectoryResource(resource)) {169//Check if directory170this.checkIfDirectoryAndPopulate(resource, server);171return noop;172}173if (isMcpResourceTemplate(resource)) {174return this._resourceTemplateToAttachment(resource).then(val => val || noop);175} else {176return this._resourceToAttachment(resource).then(val => val || noop);177}178}179180public async checkIfDirectoryAndPopulate(resource: IMcpResource | IMcpResourceTemplate, server: IMcpServer): Promise<boolean> {181try {182return !await this.navigate(resource, server);183} catch (error) {184return false;185}186}187188public async toURI(resource: IMcpResource | IMcpResourceTemplate): Promise<URI | undefined> {189if (isMcpResourceTemplate(resource)) {190const maybeUri = await this._resourceTemplateToURI(resource);191return maybeUri && await this._verifyUriIfNeeded(maybeUri);192} else {193return resource.uri;194}195}196197public checkIfNestedResources = () => !this._pickItemsStack.isEmpty();198199private async _resourceToAttachment(resource: { uri: URI; name: string; mimeType?: string }): Promise<IChatRequestVariableEntry | undefined> {200const asImage = await this._chatAttachmentResolveService.resolveImageEditorAttachContext(resource.uri, undefined, resource.mimeType);201if (asImage) {202return asImage;203}204205return {206id: resource.uri.toString(),207kind: 'file',208name: resource.name,209value: resource.uri,210};211}212213private async _resourceTemplateToAttachment(rt: IMcpResourceTemplate) {214const maybeUri = await this._resourceTemplateToURI(rt);215const uri = maybeUri && await this._verifyUriIfNeeded(maybeUri);216return uri && this._resourceToAttachment({217uri,218name: rt.name,219mimeType: rt.mimeType,220});221222}223224private async _verifyUriIfNeeded({ uri, needsVerification }: { uri: URI; needsVerification: boolean }): Promise<URI | undefined> {225if (!needsVerification) {226return uri;227}228229const exists = await this._fileService.exists(uri);230if (exists) {231return uri;232}233234this._notificationService.warn(localize('mcp.resource.template.notFound', "The resource {0} was not found.", McpResourceURI.toServer(uri).resourceURL.toString()));235return undefined;236}237238private async _resourceTemplateToURI(rt: IMcpResourceTemplate) {239const todo = rt.template.components.flatMap(c => typeof c === 'object' ? c.variables : []);240241const quickInput = this._quickInputService.createQuickPick();242const cts = new CancellationTokenSource();243244const vars: Record<string, string | string[]> = {};245quickInput.totalSteps = todo.length;246quickInput.ignoreFocusOut = true;247let needsVerification = false;248249try {250for (let i = 0; i < todo.length; i++) {251const variable = todo[i];252const resolved = await this._promptForTemplateValue(quickInput, variable, vars, rt);253if (resolved === undefined) {254return undefined;255}256// mark the URI as needing verification if any part was not a completion pick257needsVerification ||= !resolved.completed;258vars[todo[i].name] = variable.repeatable ? resolved.value.split('/') : resolved.value;259}260return { uri: rt.resolveURI(vars), needsVerification };261} finally {262cts.dispose(true);263quickInput.dispose();264}265}266267private _promptForTemplateValue(input: IQuickPick<IQuickPickItem>, variable: IUriTemplateVariable, variablesSoFar: Record<string, string | string[]>, rt: IMcpResourceTemplate): Promise<{ value: string; completed: boolean } | undefined> {268const store = new DisposableStore();269const completions = new Map<string, Promise<string[]>>([]);270271const variablesWithPlaceholders = { ...variablesSoFar };272for (const variable of rt.template.components.flatMap(c => typeof c === 'object' ? c.variables : [])) {273if (!variablesWithPlaceholders.hasOwnProperty(variable.name)) {274variablesWithPlaceholders[variable.name] = `$${variable.name.toUpperCase()}`;275}276}277278let placeholder = localize('mcp.resource.template.placeholder', "Value for ${0} in {1}", variable.name.toUpperCase(), rt.template.resolve(variablesWithPlaceholders).replaceAll('%24', '$'));279if (variable.optional) {280placeholder += ' (' + localize('mcp.resource.template.optional', "Optional") + ')';281}282283input.placeholder = placeholder;284input.value = '';285input.items = [];286input.show();287288const currentID = generateUuid();289const setItems = (value: string, completed: string[] = []) => {290const items = completed.filter(c => c !== value).map(c => ({ id: c, label: c }));291if (value) {292items.unshift({ id: currentID, label: value });293} else if (variable.optional) {294items.unshift({ id: currentID, label: localize('mcp.resource.template.empty', "<Empty>") });295}296297input.items = items;298};299300let changeCancellation = new CancellationTokenSource();301store.add(toDisposable(() => changeCancellation.dispose(true)));302303const getCompletionItems = () => {304const inputValue = input.value;305let promise = completions.get(inputValue);306if (!promise) {307promise = rt.complete(variable.name, inputValue, variablesSoFar, changeCancellation.token);308completions.set(inputValue, promise);309}310311promise.then(values => {312if (!changeCancellation.token.isCancellationRequested) {313setItems(inputValue, values);314}315}).catch(() => {316completions.delete(inputValue);317}).finally(() => {318if (!changeCancellation.token.isCancellationRequested) {319input.busy = false;320}321});322};323324const getCompletionItemsScheduler = store.add(new RunOnceScheduler(getCompletionItems, 300));325326return new Promise<{ value: string; completed: boolean } | undefined>(resolve => {327store.add(input.onDidHide(() => resolve(undefined)));328store.add(input.onDidAccept(() => {329const item = input.selectedItems[0];330if (item.id === currentID) {331resolve({ value: input.value, completed: false });332} else if (variable.explodable && item.label.endsWith('/') && item.label !== input.value) {333// if navigating in a path structure, picking a `/` should let the user pick in a subdirectory334input.value = item.label;335} else {336resolve({ value: item.label, completed: true });337}338}));339store.add(input.onDidChangeValue(value => {340input.busy = true;341changeCancellation.dispose(true);342changeCancellation = new CancellationTokenSource();343getCompletionItemsScheduler.cancel();344setItems(value);345346if (completions.has(input.value)) {347getCompletionItems();348} else {349getCompletionItemsScheduler.schedule();350}351}));352353getCompletionItems();354}).finally(() => store.dispose());355}356357private _isDirectoryResource(resource: IMcpResource | IMcpResourceTemplate): boolean {358359if (resource.mimeType && resource.mimeType === 'inode/directory') {360return true;361} else if (isMcpResourceTemplate(resource)) {362return resource.template.template.endsWith('/');363} else {364return resource.uri.path.endsWith('/');365}366}367368public getPicks(token?: CancellationToken): IObservable<{ picks: Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>; isBusy: boolean }> {369const cts = new CancellationTokenSource(token);370let isBusyLoadingPicks = true;371this._register(toDisposable(() => cts.dispose(true)));372// We try to show everything in-sequence to avoid flickering (#250411) as long as373// it loads within 5 seconds. Otherwise we just show things as the load in parallel.374let showInSequence = true;375this._register(disposableTimeout(() => {376showInSequence = false;377publish();378}, 5_000));379380const publish = () => {381const output = new Map<IMcpServer, (IMcpResourceTemplate | IMcpResource)[]>();382for (const [server, rec] of servers) {383const r: (IMcpResourceTemplate | IMcpResource)[] = [];384output.set(server, r);385if (rec.templates.isResolved) {386r.push(...rec.templates.value!);387} else if (showInSequence) {388break;389}390391r.push(...rec.resourcesSoFar);392if (!rec.resources.isSettled && showInSequence) {393break;394}395}396this._resources.set({ picks: output, isBusy: isBusyLoadingPicks }, undefined);397};398399type Rec = { templates: DeferredPromise<IMcpResourceTemplate[]>; resourcesSoFar: IMcpResource[]; resources: DeferredPromise<unknown> };400401const servers = new Map<IMcpServer, Rec>();402// Enumerate servers and start servers that need to be started to get capabilities403Promise.all((this.explicitServers || this._mcpService.servers.get()).map(async server => {404let cap = server.capabilities.get();405const rec: Rec = {406templates: new DeferredPromise(),407resourcesSoFar: [],408resources: new DeferredPromise(),409};410servers.set(server, rec); // always add it to retain order411412if (cap === undefined) {413cap = await new Promise(resolve => {414server.start().then(state => {415if (state.state === McpConnectionState.Kind.Error || state.state === McpConnectionState.Kind.Stopped) {416resolve(undefined);417}418});419this._register(cts.token.onCancellationRequested(() => resolve(undefined)));420this._register(autorun(reader => {421const cap2 = server.capabilities.read(reader);422if (cap2 !== undefined) {423resolve(cap2);424}425}));426});427}428429if (cap && (cap & McpCapability.Resources)) {430await Promise.all([431rec.templates.settleWith(server.resourceTemplates(cts.token).catch(() => [])).finally(publish),432rec.resources.settleWith((async () => {433for await (const page of server.resources(cts.token)) {434rec.resourcesSoFar = rec.resourcesSoFar.concat(page);435publish();436}437})())438]);439} else {440rec.templates.complete([]);441rec.resources.complete([]);442}443})).finally(() => {444isBusyLoadingPicks = false;445publish();446});447448// Use derived to compute the appropriate resource map based on directory navigation state449return derived(this, reader => {450const directoryResource = this._inDirectory.read(reader);451return directoryResource452? { picks: new Map([[directoryResource.server, directoryResource.resources]]), isBusy: false }453: this._resources.read(reader);454});455}456}457458export abstract class AbstractMcpResourceAccessPick {459constructor(460private readonly _scopeTo: IMcpServer | undefined,461@IInstantiationService private readonly _instantiationService: IInstantiationService,462@IEditorService private readonly _editorService: IEditorService,463@IChatWidgetService protected readonly _chatWidgetService: IChatWidgetService,464@IViewsService private readonly _viewsService: IViewsService,465) {466}467468protected applyToPick(picker: IQuickPick<IQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions) {469picker.canAcceptInBackground = true;470picker.busy = true;471picker.keepScrollPosition = true;472const store = new DisposableStore();473const goBackId = '_goback_';474475type ResourceQuickPickItem = IQuickPickItem & { resource: IMcpResource | IMcpResourceTemplate; server: IMcpServer };476477const attachButton = localize('mcp.quickaccess.attach', "Attach to chat");478479const helper = store.add(this._instantiationService.createInstance(McpResourcePickHelper));480if (this._scopeTo) {481helper.explicitServers = [this._scopeTo];482}483const picksObservable = helper.getPicks(token);484store.add(autorun(reader => {485const pickItems = picksObservable.read(reader);486const isBusy = pickItems.isBusy;487const items: (ResourceQuickPickItem | IQuickPickSeparator | IQuickPickItem)[] = [];488for (const [server, resources] of pickItems.picks) {489items.push(McpResourcePickHelper.sep(server));490for (const resource of resources) {491const pickItem = McpResourcePickHelper.item(resource);492pickItem.buttons = [{ iconClass: ThemeIcon.asClassName(Codicon.attach), tooltip: attachButton }];493items.push({ ...pickItem, resource, server });494}495}496if (helper.checkIfNestedResources()) {497// Add go back item498const goBackItem: IQuickPickItem = {499id: goBackId,500label: localize('goBack', 'Go back ↩'),501alwaysShow: true502};503items.push(goBackItem);504}505picker.items = items;506picker.busy = isBusy;507}));508509store.add(picker.onDidTriggerItemButton(event => {510if (event.button.tooltip === attachButton) {511picker.busy = true;512const resourceItem = event.item as ResourceQuickPickItem;513const attachment = helper.toAttachment(resourceItem.resource, resourceItem.server);514if (attachment instanceof Promise) {515attachment.then(async a => {516if (a !== 'noop') {517const widget = await openPanelChatAndGetWidget(this._viewsService, this._chatWidgetService);518widget?.attachmentModel.addContext(...asArray(a));519}520picker.hide();521});522}523}524}));525526store.add(picker.onDidHide(() => {527helper.dispose();528}));529530store.add(picker.onDidAccept(async event => {531try {532picker.busy = true;533const [item] = picker.selectedItems;534535// Check if go back item was selected536if (item.id === goBackId) {537helper.navigateBack();538picker.busy = false;539return;540}541542const resourceItem = item as ResourceQuickPickItem;543const resource = resourceItem.resource;544// Try to navigate into the resource if it's a directory545const isNested = await helper.navigate(resource, resourceItem.server);546if (!isNested) {547const uri = await helper.toURI(resource);548if (uri) {549picker.hide();550this._editorService.openEditor({ resource: uri, options: { preserveFocus: event.inBackground } });551}552}553} finally {554picker.busy = false;555}556}));557return store;558}559}560561export class McpResourceQuickPick extends AbstractMcpResourceAccessPick {562constructor(563scopeTo: IMcpServer | undefined,564@IInstantiationService instantiationService: IInstantiationService,565@IEditorService editorService: IEditorService,566@IChatWidgetService chatWidgetService: IChatWidgetService,567@IViewsService viewsService: IViewsService,568@IQuickInputService private readonly _quickInputService: IQuickInputService,569) {570super(scopeTo, instantiationService, editorService, chatWidgetService, viewsService);571}572573public async pick(token = CancellationToken.None) {574const store = new DisposableStore();575const qp = store.add(this._quickInputService.createQuickPick({ useSeparators: true }));576qp.placeholder = localize('mcp.quickaccess.placeholder', "Search for resources");577store.add(this.applyToPick(qp, token));578store.add(qp.onDidHide(() => store.dispose()));579qp.show();580await Event.toPromise(qp.onDidHide);581}582}583584export class McpResourceQuickAccess extends AbstractMcpResourceAccessPick implements IQuickAccessProvider {585public static readonly PREFIX = 'mcpr ';586587defaultFilterValue = DefaultQuickAccessFilterValue.LAST;588589constructor(590@IInstantiationService instantiationService: IInstantiationService,591@IEditorService editorService: IEditorService,592@IChatWidgetService chatWidgetService: IChatWidgetService,593@IViewsService viewsService: IViewsService594) {595super(undefined, instantiationService, editorService, chatWidgetService, viewsService);596}597598provide(picker: IQuickPick<IQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable {599return this.applyToPick(picker, token, runOptions);600}601}602603604