Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpServersView.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 './media/mcpServersView.css';6import * as dom from '../../../../base/browser/dom.js';7import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';8import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js';9import { Emitter, Event } from '../../../../base/common/event.js';10import { combinedDisposable, Disposable, DisposableStore, dispose, IDisposable, isDisposable } from '../../../../base/common/lifecycle.js';11import { DelayedPagedModel, IPagedModel, PagedModel } from '../../../../base/common/paging.js';12import { localize, localize2 } from '../../../../nls.js';13import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';14import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';15import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';16import { IHoverService } from '../../../../platform/hover/browser/hover.js';17import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';18import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';19import { WorkbenchPagedList } from '../../../../platform/list/browser/listService.js';20import { INotificationService } from '../../../../platform/notification/common/notification.js';21import { IOpenerService } from '../../../../platform/opener/common/opener.js';22import { IThemeService } from '../../../../platform/theme/common/themeService.js';23import { getLocationBasedViewColors } from '../../../browser/parts/views/viewPane.js';24import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.js';25import { IViewDescriptorService, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js';26import { HasInstalledMcpServersContext, IMcpWorkbenchService, InstalledMcpServersViewId, IWorkbenchMcpServer, McpServerContainers, McpServerEnablementState, McpServerInstallState } from '../common/mcpTypes.js';27import { DropDownAction, InstallAction, InstallingLabelAction, ManageMcpServerAction, McpServerStatusAction } from './mcpServerActions.js';28import { PublisherWidget, StarredWidget, McpServerIconWidget, McpServerHoverWidget, McpServerScopeBadgeWidget } from './mcpServerWidgets.js';29import { ActionRunner, IAction, Separator } from '../../../../base/common/actions.js';30import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';31import { IAllowedMcpServersService } from '../../../../platform/mcp/common/mcpManagement.js';32import { URI } from '../../../../base/common/uri.js';33import { ThemeIcon } from '../../../../base/common/themables.js';34import { IProductService } from '../../../../platform/product/common/productService.js';35import { Registry } from '../../../../platform/registry/common/platform.js';36import { IWorkbenchContribution } from '../../../common/contributions.js';37import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';38import { DefaultViewsContext, SearchMcpServersContext } from '../../extensions/common/extensions.js';39import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution.js';40import { renderMarkdown } from '../../../../base/browser/markdownRenderer.js';41import { MarkdownString } from '../../../../base/common/htmlContent.js';42import { ChatContextKeys } from '../../chat/common/chatContextKeys.js';43import { Button } from '../../../../base/browser/ui/button/button.js';44import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js';45import { AbstractExtensionsListView } from '../../extensions/browser/extensionsViews.js';46import { ExtensionListRendererOptions } from '../../extensions/browser/extensionsList.js';47import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';48import { IWorkbenchLayoutService, Position } from '../../../services/layout/browser/layoutService.js';49import { mcpServerIcon } from './mcpServerIcons.js';50import { IPagedRenderer } from '../../../../base/browser/ui/list/listPaging.js';51import { IMcpGalleryManifestService, McpGalleryManifestStatus } from '../../../../platform/mcp/common/mcpGalleryManifest.js';5253export interface McpServerListViewOptions {54showWelcomeOnEmpty?: boolean;55}5657interface IQueryResult {58model: IPagedModel<IWorkbenchMcpServer>;59disposables: DisposableStore;60showWelcomeContent?: boolean;61onDidChangeModel?: Event<IPagedModel<IWorkbenchMcpServer>>;62}6364export class McpServersListView extends AbstractExtensionsListView<IWorkbenchMcpServer> {6566private list: WorkbenchPagedList<IWorkbenchMcpServer> | null = null;67private listContainer: HTMLElement | null = null;68private welcomeContainer: HTMLElement | null = null;69private readonly contextMenuActionRunner = this._register(new ActionRunner());70private input: IQueryResult | undefined;7172constructor(73private readonly mpcViewOptions: McpServerListViewOptions,74options: IViewletViewOptions,75@IKeybindingService keybindingService: IKeybindingService,76@IContextMenuService contextMenuService: IContextMenuService,77@IInstantiationService instantiationService: IInstantiationService,78@IThemeService themeService: IThemeService,79@IHoverService hoverService: IHoverService,80@IConfigurationService configurationService: IConfigurationService,81@IContextKeyService contextKeyService: IContextKeyService,82@IViewDescriptorService viewDescriptorService: IViewDescriptorService,83@IOpenerService openerService: IOpenerService,84@IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService,85@IMcpGalleryManifestService protected readonly mcpGalleryManifestService: IMcpGalleryManifestService,86@IProductService private readonly productService: IProductService,87@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,88) {89super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);90}9192protected override renderBody(container: HTMLElement): void {93super.renderBody(container);9495// Create welcome container96this.welcomeContainer = dom.append(container, dom.$('.mcp-welcome-container.hide'));97this.createWelcomeContent(this.welcomeContainer);9899this.listContainer = dom.append(container, dom.$('.mcp-servers-list'));100this.list = this._register(this.instantiationService.createInstance(WorkbenchPagedList,101`${this.id}-MCP-Servers`,102this.listContainer,103{104getHeight() { return 72; },105getTemplateId: () => McpServerRenderer.templateId,106},107[this.instantiationService.createInstance(McpServerRenderer, {108hoverOptions: {109position: () => {110const viewLocation = this.viewDescriptorService.getViewLocationById(this.id);111if (viewLocation === ViewContainerLocation.Sidebar) {112return this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT;113}114if (viewLocation === ViewContainerLocation.AuxiliaryBar) {115return this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.LEFT : HoverPosition.RIGHT;116}117return HoverPosition.RIGHT;118}119}120})],121{122multipleSelectionSupport: false,123setRowLineHeight: false,124horizontalScrolling: false,125accessibilityProvider: {126getAriaLabel(mcpServer: IWorkbenchMcpServer | null): string {127return mcpServer?.label ?? '';128},129getWidgetAriaLabel(): string {130return localize('mcp servers', "MCP Servers");131}132},133overrideStyles: getLocationBasedViewColors(this.viewDescriptorService.getViewLocationById(this.id)).listOverrideStyles,134openOnSingleClick: true,135}) as WorkbenchPagedList<IWorkbenchMcpServer>);136this._register(Event.debounce(Event.filter(this.list.onDidOpen, e => e.element !== null), (_, event) => event, 75, true)(options => {137this.mcpWorkbenchService.open(options.element!, options.editorOptions);138}));139this._register(this.list.onContextMenu(e => this.onContextMenu(e), this));140141if (this.input) {142this.renderInput();143}144}145146private async onContextMenu(e: IListContextMenuEvent<IWorkbenchMcpServer>): Promise<void> {147if (e.element) {148const disposables = new DisposableStore();149const manageExtensionAction = disposables.add(this.instantiationService.createInstance(ManageMcpServerAction, false));150const extension = e.element ? this.mcpWorkbenchService.local.find(local => local.id === e.element!.id) || e.element151: e.element;152manageExtensionAction.mcpServer = extension;153let groups: IAction[][] = [];154if (manageExtensionAction.enabled) {155groups = await manageExtensionAction.getActionGroups();156}157const actions: IAction[] = [];158for (const menuActions of groups) {159for (const menuAction of menuActions) {160actions.push(menuAction);161if (isDisposable(menuAction)) {162disposables.add(menuAction);163}164}165actions.push(new Separator());166}167actions.pop();168this.contextMenuService.showContextMenu({169getAnchor: () => e.anchor,170getActions: () => actions,171actionRunner: this.contextMenuActionRunner,172onHide: () => disposables.dispose()173});174}175}176177protected override layoutBody(height: number, width: number): void {178super.layoutBody(height, width);179this.list?.layout(height, width);180}181182async show(query: string): Promise<IPagedModel<IWorkbenchMcpServer>> {183if (this.input) {184this.input.disposables.dispose();185this.input = undefined;186}187188this.input = await this.query(query.trim());189190this.input.showWelcomeContent = !!this.mpcViewOptions.showWelcomeOnEmpty && this.mcpGalleryManifestService.mcpGalleryManifestStatus === McpGalleryManifestStatus.Unavailable && this.input.model.length === 0;191this.renderInput();192193if (this.input.onDidChangeModel) {194this.input.disposables.add(this.input.onDidChangeModel(model => {195if (!this.input) {196return;197}198this.input.model = model;199this.input.showWelcomeContent = !!this.mpcViewOptions.showWelcomeOnEmpty && this.mcpGalleryManifestService.mcpGalleryManifestStatus === McpGalleryManifestStatus.Unavailable && this.input.model.length === 0;200this.renderInput();201}));202}203204return this.input.model;205}206207private renderInput() {208if (!this.input) {209return;210}211if (this.list) {212this.list.model = new DelayedPagedModel(this.input.model);213}214this.showWelcomeContent(!!this.input.showWelcomeContent);215}216217private showWelcomeContent(show: boolean): void {218this.welcomeContainer?.classList.toggle('hide', !show);219this.listContainer?.classList.toggle('hide', show);220}221222private createWelcomeContent(welcomeContainer: HTMLElement): void {223const welcomeContent = dom.append(welcomeContainer, dom.$('.mcp-welcome-content'));224225const iconContainer = dom.append(welcomeContent, dom.$('.mcp-welcome-icon'));226const iconElement = dom.append(iconContainer, dom.$('span'));227iconElement.className = ThemeIcon.asClassName(mcpServerIcon);228229const title = dom.append(welcomeContent, dom.$('.mcp-welcome-title'));230title.textContent = localize('mcp.welcome.title', "MCP Servers");231232const description = dom.append(welcomeContent, dom.$('.mcp-welcome-description'));233const markdownResult = this._register(renderMarkdown(new MarkdownString(234localize('mcp.welcome.descriptionWithLink', "Extend agent mode by installing MCP servers to bring extra tools for connecting to databases, invoking APIs and performing specialized tasks."),235{ isTrusted: true }236), {237actionHandler: (content: string) => {238this.openerService.open(URI.parse(content));239}240}));241description.appendChild(markdownResult.element);242243// Browse button244const buttonContainer = dom.append(welcomeContent, dom.$('.mcp-welcome-button-container'));245const button = this._register(new Button(buttonContainer, {246title: localize('mcp.welcome.browseButton', "Browse MCP Servers"),247...defaultButtonStyles248}));249button.label = localize('mcp.welcome.browseButton', "Browse MCP Servers");250251this._register(button.onDidClick(() => this.openerService.open(URI.parse(this.productService.quality === 'insider' ? 'https://code.visualstudio.com/insider/mcp' : 'https://code.visualstudio.com/mcp'))));252}253254private async query(query: string): Promise<IQueryResult> {255const disposables = new DisposableStore();256if (query) {257const servers = await this.mcpWorkbenchService.queryGallery({ text: query.replace('@mcp', '') });258return { model: new PagedModel(servers), disposables };259}260261const onDidChangeModel = disposables.add(new Emitter<IPagedModel<IWorkbenchMcpServer>>());262let servers = await this.mcpWorkbenchService.queryLocal();263disposables.add(Event.debounce(Event.filter(this.mcpWorkbenchService.onChange, e => e?.installState === McpServerInstallState.Installed), () => undefined)(() => {264const mergedMcpServers = this.mergeAddedMcpServers(servers, [...this.mcpWorkbenchService.local]);265if (mergedMcpServers) {266servers = mergedMcpServers;267onDidChangeModel.fire(new PagedModel(servers));268}269}));270disposables.add(this.mcpWorkbenchService.onReset(() => onDidChangeModel.fire(new PagedModel([...this.mcpWorkbenchService.local]))));271return { model: new PagedModel(servers), onDidChangeModel: onDidChangeModel.event, disposables };272}273274private mergeAddedMcpServers(mcpServers: IWorkbenchMcpServer[], newMcpServers: IWorkbenchMcpServer[]): IWorkbenchMcpServer[] | undefined {275const oldMcpServers = [...mcpServers];276const findPreviousMcpServerIndex = (from: number): number => {277let index = -1;278const previousMcpServerInNew = newMcpServers[from];279if (previousMcpServerInNew) {280index = oldMcpServers.findIndex(e => e.id === previousMcpServerInNew.id);281if (index === -1) {282return findPreviousMcpServerIndex(from - 1);283}284}285return index;286};287288let hasChanged: boolean = false;289for (let index = 0; index < newMcpServers.length; index++) {290const mcpServer = newMcpServers[index];291if (mcpServers.every(r => r.id !== mcpServer.id)) {292hasChanged = true;293mcpServers.splice(findPreviousMcpServerIndex(index - 1) + 1, 0, mcpServer);294}295}296297return hasChanged ? mcpServers : undefined;298}299300}301302interface IMcpServerTemplateData {303root: HTMLElement;304element: HTMLElement;305name: HTMLElement;306description: HTMLElement;307starred: HTMLElement;308mcpServer: IWorkbenchMcpServer | null;309disposables: IDisposable[];310mcpServerDisposables: IDisposable[];311actionbar: ActionBar;312}313314class McpServerRenderer implements IPagedRenderer<IWorkbenchMcpServer, IMcpServerTemplateData> {315316static readonly templateId = 'mcpServer';317readonly templateId = McpServerRenderer.templateId;318319constructor(320private readonly options: ExtensionListRendererOptions,321@IAllowedMcpServersService private readonly allowedMcpServersService: IAllowedMcpServersService,322@IInstantiationService private readonly instantiationService: IInstantiationService,323@INotificationService private readonly notificationService: INotificationService,324) { }325326renderTemplate(root: HTMLElement): IMcpServerTemplateData {327const element = dom.append(root, dom.$('.mcp-server-item.extension-list-item'));328const iconContainer = dom.append(element, dom.$('.icon-container'));329const iconWidget = this.instantiationService.createInstance(McpServerIconWidget, iconContainer);330const details = dom.append(element, dom.$('.details'));331const headerContainer = dom.append(details, dom.$('.header-container'));332const header = dom.append(headerContainer, dom.$('.header'));333const name = dom.append(header, dom.$('span.name'));334const starred = dom.append(header, dom.$('span.ratings'));335const description = dom.append(details, dom.$('.description.ellipsis'));336const footer = dom.append(details, dom.$('.footer'));337const publisherWidget = this.instantiationService.createInstance(PublisherWidget, dom.append(footer, dom.$('.publisher-container')), true);338const actionbar = new ActionBar(footer, {339actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => {340if (action instanceof DropDownAction) {341return action.createActionViewItem(options);342}343return undefined;344},345focusOnlyEnabledItems: true346});347348actionbar.setFocusable(false);349const actionBarListener = actionbar.onDidRun(({ error }) => error && this.notificationService.error(error));350const mcpServerStatusAction = this.instantiationService.createInstance(McpServerStatusAction);351352const actions = [353this.instantiationService.createInstance(InstallAction, false),354this.instantiationService.createInstance(InstallingLabelAction),355this.instantiationService.createInstance(ManageMcpServerAction, false),356mcpServerStatusAction357];358359const widgets = [360iconWidget,361publisherWidget,362this.instantiationService.createInstance(StarredWidget, starred, true),363this.instantiationService.createInstance(McpServerScopeBadgeWidget, iconContainer),364this.instantiationService.createInstance(McpServerHoverWidget, { target: root, position: this.options.hoverOptions.position }, mcpServerStatusAction)365];366const extensionContainers: McpServerContainers = this.instantiationService.createInstance(McpServerContainers, [...actions, ...widgets]);367368actionbar.push(actions, { icon: true, label: true });369const disposable = combinedDisposable(...actions, ...widgets, actionbar, actionBarListener, extensionContainers);370371return {372root, element, name, description, starred, disposables: [disposable], actionbar,373mcpServerDisposables: [],374set mcpServer(mcpServer: IWorkbenchMcpServer) {375extensionContainers.mcpServer = mcpServer;376}377};378}379380renderPlaceholder(index: number, data: IMcpServerTemplateData): void {381data.element.classList.add('loading');382383data.mcpServerDisposables = dispose(data.mcpServerDisposables);384data.name.textContent = '';385data.description.textContent = '';386data.starred.style.display = 'none';387data.mcpServer = null;388}389390renderElement(mcpServer: IWorkbenchMcpServer, index: number, data: IMcpServerTemplateData): void {391data.element.classList.remove('loading');392data.mcpServerDisposables = dispose(data.mcpServerDisposables);393data.root.setAttribute('data-mcp-server-id', mcpServer.id);394data.name.textContent = mcpServer.label;395data.description.textContent = mcpServer.description;396397data.starred.style.display = '';398data.mcpServer = mcpServer;399400const updateEnablement = () => {401const disabled = !!mcpServer.local &&402(mcpServer.installState === McpServerInstallState.Installed403? mcpServer.enablementState === McpServerEnablementState.DisabledByAccess404: mcpServer.installState === McpServerInstallState.Uninstalled);405data.root.classList.toggle('disabled', disabled);406};407updateEnablement();408this.allowedMcpServersService.onDidChangeAllowedMcpServers(() => updateEnablement(), this, data.mcpServerDisposables);409}410411disposeElement(mcpServer: IWorkbenchMcpServer, index: number, data: IMcpServerTemplateData): void {412data.mcpServerDisposables = dispose(data.mcpServerDisposables);413}414415disposeTemplate(data: IMcpServerTemplateData): void {416data.mcpServerDisposables = dispose(data.mcpServerDisposables);417data.disposables = dispose(data.disposables);418}419}420421422export class DefaultBrowseMcpServersView extends McpServersListView {423424protected override renderBody(container: HTMLElement): void {425super.renderBody(container);426this._register(this.mcpGalleryManifestService.onDidChangeMcpGalleryManifest(() => this.show()));427}428429override async show(): Promise<IPagedModel<IWorkbenchMcpServer>> {430return super.show('@mcp');431}432}433434export class McpServersViewsContribution extends Disposable implements IWorkbenchContribution {435436static ID = 'workbench.mcp.servers.views.contribution';437438constructor() {439super();440441Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([442{443id: InstalledMcpServersViewId,444name: localize2('mcp-installed', "MCP Servers - Installed"),445ctorDescriptor: new SyncDescriptor(McpServersListView, [{ showWelcomeOnEmpty: false }]),446when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext),447weight: 40,448order: 4,449canToggleVisibility: true450},451{452id: 'workbench.views.mcp.default.marketplace',453name: localize2('mcp', "MCP Servers"),454ctorDescriptor: new SyncDescriptor(DefaultBrowseMcpServersView, [{ showWelcomeOnEmpty: true }]),455when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext.toNegated(), ChatContextKeys.Setup.hidden.negate()),456weight: 40,457order: 4,458canToggleVisibility: true459},460{461id: 'workbench.views.mcp.marketplace',462name: localize2('mcp', "MCP Servers"),463ctorDescriptor: new SyncDescriptor(McpServersListView, [{ showWelcomeOnEmpty: true }]),464when: ContextKeyExpr.and(SearchMcpServersContext),465}466], VIEW_CONTAINER);467}468}469470471