Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts
5291 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/mcpServerEditor.css';6import { $, Dimension, append, clearNode, setParentFlowTo } from '../../../../base/browser/dom.js';7import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';8import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';9import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js';10import { Action, IAction } from '../../../../base/common/actions.js';11import * as arrays from '../../../../base/common/arrays.js';12import { Cache, CacheResult } from '../../../../base/common/cache.js';13import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';14import { isCancellationError } from '../../../../base/common/errors.js';15import { Emitter, Event } from '../../../../base/common/event.js';16import { Disposable, DisposableStore, MutableDisposable, dispose, toDisposable } from '../../../../base/common/lifecycle.js';17import { Schemas, matchesScheme } from '../../../../base/common/network.js';18import { URI } from '../../../../base/common/uri.js';19import { generateUuid } from '../../../../base/common/uuid.js';20import { TokenizationRegistry } from '../../../../editor/common/languages.js';21import { ILanguageService } from '../../../../editor/common/languages/language.js';22import { generateTokensCSSForColorMap } from '../../../../editor/common/languages/supports/tokenization.js';23import { localize } from '../../../../nls.js';24import { IContextKeyService, IScopedContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';25import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';26import { INotificationService } from '../../../../platform/notification/common/notification.js';27import { IOpenerService } from '../../../../platform/opener/common/opener.js';28import { IStorageService } from '../../../../platform/storage/common/storage.js';29import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';30import { IThemeService } from '../../../../platform/theme/common/themeService.js';31import { EditorPane } from '../../../browser/parts/editor/editorPane.js';32import { IEditorOpenContext } from '../../../common/editor.js';33import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from '../../markdown/browser/markdownDocumentRenderer.js';34import { IWebview, IWebviewService } from '../../webview/browser/webview.js';35import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';36import { IExtensionService } from '../../../services/extensions/common/extensions.js';37import { IHoverService } from '../../../../platform/hover/browser/hover.js';38import { IMcpServerContainer, IMcpServerEditorOptions, IMcpWorkbenchService, IWorkbenchMcpServer, McpServerContainers, McpServerInstallState } from '../common/mcpTypes.js';39import { StarredWidget, McpServerIconWidget, McpServerStatusWidget, McpServerWidget, onClick, PublisherWidget, McpServerScopeBadgeWidget, LicenseWidget } from './mcpServerWidgets.js';40import { ButtonWithDropDownExtensionAction, ButtonWithDropdownExtensionActionViewItem, DropDownAction, InstallAction, InstallingLabelAction, InstallInRemoteAction, InstallInWorkspaceAction, ManageMcpServerAction, McpServerStatusAction, UninstallAction } from './mcpServerActions.js';41import { McpServerEditorInput } from './mcpServerEditorInput.js';42import { ILocalMcpServer, IGalleryMcpServerConfiguration, IMcpServerPackage, IMcpServerKeyValueInput, RegistryType } from '../../../../platform/mcp/common/mcpManagement.js';43import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';44import { McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js';45import { ThemeIcon } from '../../../../base/common/themables.js';46import { Codicon } from '../../../../base/common/codicons.js';47import { getMcpGalleryManifestResourceUri, IMcpGalleryManifestService, McpGalleryResourceType } from '../../../../platform/mcp/common/mcpGalleryManifest.js';48import { fromNow } from '../../../../base/common/date.js';49import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';5051const enum McpServerEditorTab {52Readme = 'readme',53Configuration = 'configuration',54Manifest = 'manifest',55}5657class NavBar extends Disposable {5859private _onChange = this._register(new Emitter<{ id: string | null; focus: boolean }>());60get onChange(): Event<{ id: string | null; focus: boolean }> { return this._onChange.event; }6162private _currentId: string | null = null;63get currentId(): string | null { return this._currentId; }6465private actions: Action[];66private actionbar: ActionBar;6768constructor(container: HTMLElement) {69super();70const element = append(container, $('.navbar'));71this.actions = [];72this.actionbar = this._register(new ActionBar(element));73}7475push(id: string, label: string, tooltip: string, index?: number): void {76const action = new Action(id, label, undefined, true, () => this.update(id, true));7778action.tooltip = tooltip;7980if (typeof index === 'number') {81this.actions.splice(index, 0, action);82} else {83this.actions.push(action);84}85this.actionbar.push(action, { index });8687if (this.actions.length === 1) {88this.update(id);89}90}9192remove(id: string): void {93const index = this.actions.findIndex(action => action.id === id);94if (index !== -1) {95this.actions.splice(index, 1);96this.actionbar.pull(index);97if (this._currentId === id) {98this.switch(this.actions[0]?.id);99}100}101}102103clear(): void {104this.actions = dispose(this.actions);105this.actionbar.clear();106}107108switch(id: string): boolean {109const action = this.actions.find(action => action.id === id);110if (action) {111action.run();112return true;113}114return false;115}116117has(id: string): boolean {118return this.actions.some(action => action.id === id);119}120121private update(id: string, focus?: boolean): void {122this._currentId = id;123this._onChange.fire({ id, focus: !!focus });124this.actions.forEach(a => a.checked = a.id === id);125}126}127128interface ILayoutParticipant {129layout(): void;130}131132interface IActiveElement {133focus(): void;134}135136interface IExtensionEditorTemplate {137name: HTMLElement;138description: HTMLElement;139actionsAndStatusContainer: HTMLElement;140actionBar: ActionBar;141navbar: NavBar;142content: HTMLElement;143header: HTMLElement;144mcpServer: IWorkbenchMcpServer;145}146147const enum WebviewIndex {148Readme,149Changelog150}151152export class McpServerEditor extends EditorPane {153154static readonly ID: string = 'workbench.editor.mcpServer';155156private readonly _scopedContextKeyService = this._register(new MutableDisposable<IScopedContextKeyService>());157private template: IExtensionEditorTemplate | undefined;158159private mcpServerReadme: Cache<string> | null;160private mcpServerManifest: Cache<IGalleryMcpServerConfiguration> | null;161162// Some action bar items use a webview whose vertical scroll position we track in this map163private initialScrollProgress: Map<WebviewIndex, number> = new Map();164165// Spot when an ExtensionEditor instance gets reused for a different extension, in which case the vertical scroll positions must be zeroed166private currentIdentifier: string = '';167168private layoutParticipants: ILayoutParticipant[] = [];169private readonly contentDisposables = this._register(new DisposableStore());170private readonly transientDisposables = this._register(new DisposableStore());171private activeElement: IActiveElement | null = null;172private dimension: Dimension | undefined;173174constructor(175group: IEditorGroup,176@ITelemetryService telemetryService: ITelemetryService,177@IInstantiationService private readonly instantiationService: IInstantiationService,178@IThemeService themeService: IThemeService,179@INotificationService private readonly notificationService: INotificationService,180@IOpenerService private readonly openerService: IOpenerService,181@IStorageService storageService: IStorageService,182@IExtensionService private readonly extensionService: IExtensionService,183@IWebviewService private readonly webviewService: IWebviewService,184@ILanguageService private readonly languageService: ILanguageService,185@IContextKeyService private readonly contextKeyService: IContextKeyService,186@IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService,187@IHoverService private readonly hoverService: IHoverService,188@IContextMenuService private readonly contextMenuService: IContextMenuService,189) {190super(McpServerEditor.ID, group, telemetryService, themeService, storageService);191this.mcpServerReadme = null;192this.mcpServerManifest = null;193}194195override get scopedContextKeyService(): IContextKeyService | undefined {196return this._scopedContextKeyService.value;197}198199protected createEditor(parent: HTMLElement): void {200const root = append(parent, $('.extension-editor.mcp-server-editor'));201this._scopedContextKeyService.value = this.contextKeyService.createScoped(root);202this._scopedContextKeyService.value.createKey('inExtensionEditor', true);203204root.tabIndex = 0; // this is required for the focus tracker on the editor205root.style.outline = 'none';206root.setAttribute('role', 'document');207const header = append(root, $('.header'));208209const iconContainer = append(header, $('.icon-container'));210const iconWidget = this.instantiationService.createInstance(McpServerIconWidget, iconContainer);211const scopeWidget = this.instantiationService.createInstance(McpServerScopeBadgeWidget, iconContainer);212213const details = append(header, $('.details'));214const title = append(details, $('.title'));215const name = append(title, $('span.name.clickable', { role: 'heading', tabIndex: 0 }));216this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), name, localize('name', "Extension name")));217218const subtitle = append(details, $('.subtitle'));219const subTitleEntryContainers: HTMLElement[] = [];220221const publisherContainer = append(subtitle, $('.subtitle-entry'));222subTitleEntryContainers.push(publisherContainer);223const publisherWidget = this.instantiationService.createInstance(PublisherWidget, publisherContainer, false);224225const starredContainer = append(subtitle, $('.subtitle-entry'));226subTitleEntryContainers.push(starredContainer);227const installCountWidget = this.instantiationService.createInstance(StarredWidget, starredContainer, false);228229const licenseContainer = append(subtitle, $('.subtitle-entry'));230subTitleEntryContainers.push(licenseContainer);231const licenseWidget = this.instantiationService.createInstance(LicenseWidget, licenseContainer);232233const widgets: McpServerWidget[] = [234iconWidget,235publisherWidget,236installCountWidget,237scopeWidget,238licenseWidget239];240241const description = append(details, $('.description'));242243const actions = [244this.instantiationService.createInstance(InstallAction, false),245this.instantiationService.createInstance(InstallingLabelAction),246this.instantiationService.createInstance(ButtonWithDropDownExtensionAction, 'extensions.uninstall', UninstallAction.CLASS, [247[248this.instantiationService.createInstance(UninstallAction),249this.instantiationService.createInstance(InstallInWorkspaceAction, false),250this.instantiationService.createInstance(InstallInRemoteAction, false)251]252]),253this.instantiationService.createInstance(ManageMcpServerAction, true),254];255256const actionsAndStatusContainer = append(details, $('.actions-status-container.mcp-server-actions'));257const actionBar = this._register(new ActionBar(actionsAndStatusContainer, {258actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => {259if (action instanceof DropDownAction) {260return action.createActionViewItem(options);261}262if (action instanceof ButtonWithDropDownExtensionAction) {263return new ButtonWithDropdownExtensionActionViewItem(264action,265{266...options,267icon: true,268label: true,269menuActionsOrProvider: { getActions: () => action.menuActions },270menuActionClassNames: action.menuActionClassNames271},272this.contextMenuService);273}274return undefined;275},276focusOnlyEnabledItems: true277}));278279actionBar.push(actions, { icon: true, label: true });280actionBar.setFocusable(true);281// update focusable elements when the enablement of an action changes282this._register(Event.any(...actions.map(a => Event.filter(a.onDidChange, e => e.enabled !== undefined)))(() => {283actionBar.setFocusable(false);284actionBar.setFocusable(true);285}));286287const otherContainers: IMcpServerContainer[] = [];288const mcpServerStatusAction = this.instantiationService.createInstance(McpServerStatusAction);289const mcpServerStatusWidget = this._register(this.instantiationService.createInstance(McpServerStatusWidget, append(actionsAndStatusContainer, $('.status')), mcpServerStatusAction));290this._register(Event.any(mcpServerStatusWidget.onDidRender)(() => {291if (this.dimension) {292this.layout(this.dimension);293}294}));295296otherContainers.push(mcpServerStatusAction, new class extends McpServerWidget {297render() {298actionsAndStatusContainer.classList.toggle('list-layout', this.mcpServer?.installState === McpServerInstallState.Installed);299}300}());301302const mcpServerContainers: McpServerContainers = this.instantiationService.createInstance(McpServerContainers, [...actions, ...widgets, ...otherContainers]);303for (const disposable of [...actions, ...widgets, ...otherContainers, mcpServerContainers]) {304this._register(disposable);305}306307const onError = Event.chain(actionBar.onDidRun, $ =>308$.map(({ error }) => error)309.filter(error => !!error)310);311312this._register(onError(this.onError, this));313314const body = append(root, $('.body'));315const navbar = new NavBar(body);316317const content = append(body, $('.content'));318content.id = generateUuid(); // An id is needed for the webview parent flow to319320this.template = {321content,322description,323header,324name,325navbar,326actionsAndStatusContainer,327actionBar: actionBar,328set mcpServer(mcpServer: IWorkbenchMcpServer) {329mcpServerContainers.mcpServer = mcpServer;330let lastNonEmptySubtitleEntryContainer;331for (const subTitleEntryElement of subTitleEntryContainers) {332subTitleEntryElement.classList.remove('last-non-empty');333if (subTitleEntryElement.children.length > 0) {334lastNonEmptySubtitleEntryContainer = subTitleEntryElement;335}336}337if (lastNonEmptySubtitleEntryContainer) {338lastNonEmptySubtitleEntryContainer.classList.add('last-non-empty');339}340}341};342}343344override async setInput(input: McpServerEditorInput, options: IMcpServerEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {345await super.setInput(input, options, context, token);346if (this.template) {347await this.render(input.mcpServer, this.template, !!options?.preserveFocus);348}349}350351private async render(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, preserveFocus: boolean): Promise<void> {352this.activeElement = null;353this.transientDisposables.clear();354355const token = this.transientDisposables.add(new CancellationTokenSource()).token;356357this.mcpServerReadme = new Cache(() => mcpServer.getReadme(token));358this.mcpServerManifest = new Cache(() => mcpServer.getManifest(token));359template.mcpServer = mcpServer;360361template.name.textContent = mcpServer.label;362template.name.classList.toggle('clickable', !!mcpServer.gallery?.webUrl);363template.description.textContent = mcpServer.description;364if (mcpServer.gallery?.webUrl) {365this.transientDisposables.add(onClick(template.name, () => this.openerService.open(URI.parse(mcpServer.gallery?.webUrl!))));366}367368this.renderNavbar(mcpServer, template, preserveFocus);369}370371override setOptions(options: IMcpServerEditorOptions | undefined): void {372super.setOptions(options);373if (options?.tab) {374this.template?.navbar.switch(options.tab);375}376}377378private renderNavbar(extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, preserveFocus: boolean): void {379template.content.innerText = '';380template.navbar.clear();381382if (this.currentIdentifier !== extension.id) {383this.initialScrollProgress.clear();384this.currentIdentifier = extension.id;385}386387if (extension.readmeUrl || extension.gallery?.readme) {388template.navbar.push(McpServerEditorTab.Readme, localize('details', "Details"), localize('detailstooltip', "Extension details, rendered from the extension's 'README.md' file"));389}390391if (extension.gallery || extension.local?.manifest) {392template.navbar.push(McpServerEditorTab.Manifest, localize('manifest', "Manifest"), localize('manifesttooltip', "Server manifest details"));393}394395if (extension.config) {396template.navbar.push(McpServerEditorTab.Configuration, localize('configuration', "Configuration"), localize('configurationtooltip', "Server configuration details"));397}398399this.transientDisposables.add(this.mcpWorkbenchService.onChange(e => {400if (e === extension) {401if (e.config && !template.navbar.has(McpServerEditorTab.Configuration)) {402template.navbar.push(McpServerEditorTab.Configuration, localize('configuration', "Configuration"), localize('configurationtooltip', "Server configuration details"), extension.readmeUrl ? 1 : 0);403}404if (!e.config && template.navbar.has(McpServerEditorTab.Configuration)) {405template.navbar.remove(McpServerEditorTab.Configuration);406}407}408}));409410if ((<IMcpServerEditorOptions | undefined>this.options)?.tab) {411template.navbar.switch((<IMcpServerEditorOptions>this.options).tab!);412}413414if (template.navbar.currentId) {415this.onNavbarChange(extension, { id: template.navbar.currentId, focus: !preserveFocus }, template);416}417template.navbar.onChange(e => this.onNavbarChange(extension, e, template), this, this.transientDisposables);418}419420override clearInput(): void {421this.contentDisposables.clear();422this.transientDisposables.clear();423424super.clearInput();425}426427override focus(): void {428super.focus();429this.activeElement?.focus();430}431432showFind(): void {433this.activeWebview?.showFind();434}435436runFindAction(previous: boolean): void {437this.activeWebview?.runFindAction(previous);438}439440public get activeWebview(): IWebview | undefined {441if (!this.activeElement || !(this.activeElement as IWebview).runFindAction) {442return undefined;443}444return this.activeElement as IWebview;445}446447private onNavbarChange(extension: IWorkbenchMcpServer, { id, focus }: { id: string | null; focus: boolean }, template: IExtensionEditorTemplate): void {448this.contentDisposables.clear();449template.content.innerText = '';450this.activeElement = null;451if (id) {452const cts = new CancellationTokenSource();453this.contentDisposables.add(toDisposable(() => cts.dispose(true)));454this.open(id, extension, template, cts.token)455.then(activeElement => {456if (cts.token.isCancellationRequested) {457return;458}459this.activeElement = activeElement;460if (focus) {461this.focus();462}463});464}465}466467private open(id: string, extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {468switch (id) {469case McpServerEditorTab.Configuration: return this.openConfiguration(extension, template, token);470case McpServerEditorTab.Readme: return this.openDetails(extension, template, token);471case McpServerEditorTab.Manifest: return extension.readmeUrl ? this.openManifest(extension, template.content, token) : this.openManifestWithAdditionalDetails(extension, template, token);472}473return Promise.resolve(null);474}475476private async openMarkdown(extension: IWorkbenchMcpServer, cacheResult: CacheResult<string>, noContentCopy: string, container: HTMLElement, webviewIndex: WebviewIndex, title: string, token: CancellationToken): Promise<IActiveElement | null> {477try {478const body = await this.renderMarkdown(extension, cacheResult, container, token);479if (token.isCancellationRequested) {480return Promise.resolve(null);481}482483const webview = this.contentDisposables.add(this.webviewService.createWebviewOverlay({484title,485options: {486enableFindWidget: true,487tryRestoreScrollPosition: true,488disableServiceWorker: true,489},490contentOptions: {},491extension: undefined,492}));493494webview.initialScrollProgress = this.initialScrollProgress.get(webviewIndex) || 0;495496webview.claim(this, this.window, this.scopedContextKeyService);497setParentFlowTo(webview.container, container);498webview.layoutWebviewOverElement(container);499500webview.setHtml(body);501webview.claim(this, this.window, undefined);502503this.contentDisposables.add(webview.onDidFocus(() => this._onDidFocus?.fire()));504505this.contentDisposables.add(webview.onDidScroll(() => this.initialScrollProgress.set(webviewIndex, webview.initialScrollProgress)));506507const removeLayoutParticipant = arrays.insert(this.layoutParticipants, {508layout: () => {509webview.layoutWebviewOverElement(container);510}511});512this.contentDisposables.add(toDisposable(removeLayoutParticipant));513514let isDisposed = false;515this.contentDisposables.add(toDisposable(() => { isDisposed = true; }));516517this.contentDisposables.add(this.themeService.onDidColorThemeChange(async () => {518// Render again since syntax highlighting of code blocks may have changed519const body = await this.renderMarkdown(extension, cacheResult, container);520if (!isDisposed) { // Make sure we weren't disposed of in the meantime521webview.setHtml(body);522}523}));524525this.contentDisposables.add(webview.onDidClickLink(link => {526if (!link) {527return;528}529// Only allow links with specific schemes530if (matchesScheme(link, Schemas.http) || matchesScheme(link, Schemas.https) || matchesScheme(link, Schemas.mailto)) {531this.openerService.open(link);532}533}));534535return webview;536} catch (e) {537const p = append(container, $('p.nocontent'));538p.textContent = noContentCopy;539return p;540}541}542543private async renderMarkdown(extension: IWorkbenchMcpServer, cacheResult: CacheResult<string>, container: HTMLElement, token?: CancellationToken): Promise<string> {544const contents = await this.loadContents(() => cacheResult, container);545if (token?.isCancellationRequested) {546return '';547}548549const content = await renderMarkdownDocument(contents, this.extensionService, this.languageService, {}, token);550if (token?.isCancellationRequested) {551return '';552}553554return this.renderBody(content);555}556557private renderBody(body: TrustedHTML): string {558const nonce = generateUuid();559const colorMap = TokenizationRegistry.getColorMap();560const css = colorMap ? generateTokensCSSForColorMap(colorMap) : '';561return `<!DOCTYPE html>562<html>563<head>564<meta http-equiv="Content-type" content="text/html;charset=UTF-8">565<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: data:; media-src https:; script-src 'none'; style-src 'nonce-${nonce}';">566<style nonce="${nonce}">567${DEFAULT_MARKDOWN_STYLES}568569/* prevent scroll-to-top button from blocking the body text */570body {571padding-bottom: 75px;572}573574#scroll-to-top {575position: fixed;576width: 32px;577height: 32px;578right: 25px;579bottom: 25px;580background-color: var(--vscode-button-secondaryBackground);581border-color: var(--vscode-button-border);582border-radius: 50%;583cursor: pointer;584box-shadow: 1px 1px 1px rgba(0,0,0,.25);585outline: none;586display: flex;587justify-content: center;588align-items: center;589}590591#scroll-to-top:hover {592background-color: var(--vscode-button-secondaryHoverBackground);593box-shadow: 2px 2px 2px rgba(0,0,0,.25);594}595596body.vscode-high-contrast #scroll-to-top {597border-width: 2px;598border-style: solid;599box-shadow: none;600}601602#scroll-to-top span.icon::before {603content: "";604display: block;605background: var(--vscode-button-secondaryForeground);606/* Chevron up icon */607webkit-mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAxNiAxNiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTYgMTY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkZGRkZGO30KCS5zdDF7ZmlsbDpub25lO30KPC9zdHlsZT4KPHRpdGxlPnVwY2hldnJvbjwvdGl0bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik04LDUuMWwtNy4zLDcuM0wwLDExLjZsOC04bDgsOGwtMC43LDAuN0w4LDUuMXoiLz4KPHJlY3QgY2xhc3M9InN0MSIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2Ii8+Cjwvc3ZnPgo=');608-webkit-mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAxNiAxNiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTYgMTY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkZGRkZGO30KCS5zdDF7ZmlsbDpub25lO30KPC9zdHlsZT4KPHRpdGxlPnVwY2hldnJvbjwvdGl0bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik04LDUuMWwtNy4zLDcuM0wwLDExLjZsOC04bDgsOGwtMC43LDAuN0w4LDUuMXoiLz4KPHJlY3QgY2xhc3M9InN0MSIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2Ii8+Cjwvc3ZnPgo=');609width: 16px;610height: 16px;611}612${css}613</style>614</head>615<body>616<a id="scroll-to-top" role="button" aria-label="scroll to top" href="#"><span class="icon"></span></a>617${body}618</body>619</html>`;620}621622private async openDetails(extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {623const details = append(template.content, $('.details'));624const readmeContainer = append(details, $('.content-container'));625const additionalDetailsContainer = append(details, $('.additional-details-container'));626627const layout = () => details.classList.toggle('narrow', this.dimension && this.dimension.width < 500);628layout();629this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));630631const activeElement = await this.openMarkdown(extension, this.mcpServerReadme!.get(), localize('noReadme', "No README available."), readmeContainer, WebviewIndex.Readme, localize('Readme title', "Readme"), token);632this.renderAdditionalDetails(additionalDetailsContainer, extension);633return activeElement;634}635636private async openConfiguration(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {637const configContainer = append(template.content, $('.configuration'));638const content = $('div', { class: 'configuration-content' });639640this.renderConfigurationDetails(content, mcpServer);641642const scrollableContent = new DomScrollableElement(content, {});643const layout = () => scrollableContent.scanDomNode();644this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));645646append(configContainer, scrollableContent.getDomNode());647648return { focus: () => content.focus() };649}650651private async openManifestWithAdditionalDetails(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {652const details = append(template.content, $('.details'));653654const readmeContainer = append(details, $('.content-container'));655const additionalDetailsContainer = append(details, $('.additional-details-container'));656657const layout = () => details.classList.toggle('narrow', this.dimension && this.dimension.width < 500);658layout();659this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));660661const activeElement = await this.openManifest(mcpServer, readmeContainer, token);662663this.renderAdditionalDetails(additionalDetailsContainer, mcpServer);664return activeElement;665}666667private async openManifest(mcpServer: IWorkbenchMcpServer, parent: HTMLElement, token: CancellationToken): Promise<IActiveElement | null> {668const manifestContainer = append(parent, $('.manifest'));669const content = $('div', { class: 'manifest-content' });670671try {672const manifest = await this.loadContents(() => this.mcpServerManifest!.get(), content);673if (token.isCancellationRequested) {674return null;675}676this.renderManifestDetails(content, manifest);677} catch (error) {678// Handle error - show no manifest message679while (content.firstChild) {680content.removeChild(content.firstChild);681}682const noManifestMessage = append(content, $('.no-manifest'));683noManifestMessage.textContent = localize('noManifest', "No manifest available for this MCP server.");684}685686const scrollableContent = new DomScrollableElement(content, {});687const layout = () => scrollableContent.scanDomNode();688this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));689690append(manifestContainer, scrollableContent.getDomNode());691692return { focus: () => content.focus() };693}694695private renderConfigurationDetails(container: HTMLElement, mcpServer: IWorkbenchMcpServer): void {696clearNode(container);697698const config = mcpServer.config;699700if (!config) {701const noConfigMessage = append(container, $('.no-config'));702noConfigMessage.textContent = localize('noConfig', "No configuration available for this MCP server.");703return;704}705706// Server Name707const nameSection = append(container, $('.config-section'));708const nameLabel = append(nameSection, $('.config-label'));709nameLabel.textContent = localize('serverName', "Name:");710const nameValue = append(nameSection, $('.config-value'));711nameValue.textContent = mcpServer.name;712713// Server Type714const typeSection = append(container, $('.config-section'));715const typeLabel = append(typeSection, $('.config-label'));716typeLabel.textContent = localize('serverType', "Type:");717const typeValue = append(typeSection, $('.config-value'));718typeValue.textContent = config.type;719720// Type-specific configuration721if (config.type === McpServerType.LOCAL) {722// Command723const commandSection = append(container, $('.config-section'));724const commandLabel = append(commandSection, $('.config-label'));725commandLabel.textContent = localize('command', "Command:");726const commandValue = append(commandSection, $('code.config-value'));727commandValue.textContent = config.command;728729// Arguments (if present)730if (config.args && config.args.length > 0) {731const argsSection = append(container, $('.config-section'));732const argsLabel = append(argsSection, $('.config-label'));733argsLabel.textContent = localize('arguments', "Arguments:");734const argsValue = append(argsSection, $('code.config-value'));735argsValue.textContent = config.args.join(' ');736}737} else if (config.type === McpServerType.REMOTE) {738// URL739const urlSection = append(container, $('.config-section'));740const urlLabel = append(urlSection, $('.config-label'));741urlLabel.textContent = localize('url', "URL:");742const urlValue = append(urlSection, $('code.config-value'));743urlValue.textContent = config.url;744}745}746747private renderManifestDetails(container: HTMLElement, manifest: IGalleryMcpServerConfiguration): void {748clearNode(container);749750if (manifest.packages && manifest.packages.length > 0) {751const packagesByType = new Map<RegistryType, IMcpServerPackage[]>();752for (const pkg of manifest.packages) {753const type = pkg.registryType;754let packages = packagesByType.get(type);755if (!packages) {756packagesByType.set(type, packages = []);757}758packages.push(pkg);759}760761append(container, $('.manifest-section', undefined, $('.manifest-section-title', undefined, localize('packages', "Packages"))));762763for (const [packageType, packages] of packagesByType) {764const packageSection = append(container, $('.package-section', undefined, $('.package-section-title', undefined, packageType.toUpperCase())));765const packagesGrid = append(packageSection, $('.package-details'));766767for (let i = 0; i < packages.length; i++) {768const pkg = packages[i];769append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('packageName', "Package:")), $('.detail-value', undefined, pkg.identifier)));770if (pkg.packageArguments && pkg.packageArguments.length > 0) {771const argStrings: string[] = [];772for (const arg of pkg.packageArguments) {773if (arg.type === 'named') {774argStrings.push(arg.name);775if (arg.value) {776argStrings.push(arg.value);777}778}779if (arg.type === 'positional') {780const val = arg.value ?? arg.valueHint;781if (val) {782argStrings.push(val);783}784}785}786append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('packagearguments', "Package Arguments:")), $('code.detail-value', undefined, argStrings.join(' '))));787}788if (pkg.runtimeArguments && pkg.runtimeArguments.length > 0) {789const argStrings: string[] = [];790for (const arg of pkg.runtimeArguments) {791if (arg.type === 'named') {792argStrings.push(arg.name);793if (arg.value) {794argStrings.push(arg.value);795}796}797if (arg.type === 'positional') {798const val = arg.value ?? arg.valueHint;799if (val) {800argStrings.push(val);801}802}803}804append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('runtimeargs', "Runtime Arguments:")), $('code.detail-value', undefined, argStrings.join(' '))));805}806if (pkg.environmentVariables && pkg.environmentVariables.length > 0) {807const envStrings = pkg.environmentVariables.map((envVar: IMcpServerKeyValueInput) => `${envVar.name}=${envVar.value ?? ''}`);808append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('environmentVariables', "Environment Variables:")), $('code.detail-value', undefined, envStrings.join(' '))));809}810if (i < packages.length - 1) {811append(packagesGrid, $('.package-separator'));812}813}814}815}816817if (manifest.remotes && manifest.remotes.length > 0) {818const packageSection = append(container, $('.package-section', undefined, $('.package-section-title', undefined, localize('remotes', "Remote").toLocaleUpperCase())));819for (const remote of manifest.remotes) {820const packagesGrid = append(packageSection, $('.package-details'));821append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('url', "URL:")), $('.detail-value', undefined, remote.url)));822if (remote.type) {823append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('transport', "Transport:")), $('.detail-value', undefined, remote.type)));824}825if (remote.headers && remote.headers.length > 0) {826const headerStrings = remote.headers.map((header: IMcpServerKeyValueInput) => `${header.name}: ${header.value ?? ''}`);827append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('headers', "Headers:")), $('.detail-value', undefined, headerStrings.join(', '))));828}829}830}831}832833private renderAdditionalDetails(container: HTMLElement, extension: IWorkbenchMcpServer): void {834const content = $('div', { class: 'additional-details-content', tabindex: '0' });835const scrollableContent = new DomScrollableElement(content, {});836const layout = () => scrollableContent.scanDomNode();837const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { layout });838this.contentDisposables.add(toDisposable(removeLayoutParticipant));839this.contentDisposables.add(scrollableContent);840841this.contentDisposables.add(this.instantiationService.createInstance(AdditionalDetailsWidget, content, extension));842843append(container, scrollableContent.getDomNode());844scrollableContent.scanDomNode();845}846847private loadContents<T>(loadingTask: () => CacheResult<T>, container: HTMLElement): Promise<T> {848container.classList.add('loading');849850const result = this.contentDisposables.add(loadingTask());851const onDone = () => container.classList.remove('loading');852result.promise.then(onDone, onDone);853854return result.promise;855}856857layout(dimension: Dimension): void {858this.dimension = dimension;859this.layoutParticipants.forEach(p => p.layout());860}861862private onError(err: Error): void {863if (isCancellationError(err)) {864return;865}866867this.notificationService.error(err);868}869}870871class AdditionalDetailsWidget extends Disposable {872873private readonly disposables = this._register(new DisposableStore());874875constructor(876private readonly container: HTMLElement,877extension: IWorkbenchMcpServer,878@IMcpGalleryManifestService private readonly mcpGalleryManifestService: IMcpGalleryManifestService,879@IHoverService private readonly hoverService: IHoverService,880@IOpenerService private readonly openerService: IOpenerService,881) {882super();883this.render(extension);884this._register(this.mcpGalleryManifestService.onDidChangeMcpGalleryManifest(() => this.render(extension)));885}886887private render(extension: IWorkbenchMcpServer): void {888this.container.innerText = '';889this.disposables.clear();890891if (extension.local) {892this.renderInstallInfo(this.container, extension.local);893}894895if (extension.gallery) {896this.renderMarketplaceInfo(this.container, extension);897}898this.renderTags(this.container, extension);899this.renderExtensionResources(this.container, extension);900}901902private renderTags(container: HTMLElement, extension: IWorkbenchMcpServer): void {903if (extension.gallery?.topics?.length) {904const categoriesContainer = append(container, $('.categories-container.additional-details-element'));905append(categoriesContainer, $('.additional-details-title', undefined, localize('tags', "Tags")));906const categoriesElement = append(categoriesContainer, $('.categories'));907for (const category of extension.gallery.topics) {908append(categoriesElement, $('span.category', { tabindex: '0' }, category));909}910}911}912913private async renderExtensionResources(container: HTMLElement, extension: IWorkbenchMcpServer): Promise<void> {914const resources: [string, ThemeIcon, URI][] = [];915const manifest = await this.mcpGalleryManifestService.getMcpGalleryManifest();916if (extension.repository) {917try {918resources.push([localize('repository', "Repository"), ThemeIcon.fromId(Codicon.repo.id), URI.parse(extension.repository)]);919} catch (error) {/* Ignore */ }920}921if (manifest) {922const supportUri = getMcpGalleryManifestResourceUri(manifest, McpGalleryResourceType.ContactSupportUri);923if (supportUri) {924try {925resources.push([localize('support', "Contact Support"), ThemeIcon.fromId(Codicon.commentDiscussion.id), URI.parse(supportUri)]);926} catch (error) {/* Ignore */ }927}928}929if (resources.length) {930const extensionResourcesContainer = append(container, $('.resources-container.additional-details-element'));931append(extensionResourcesContainer, $('.additional-details-title', undefined, localize('resources', "Resources")));932const resourcesElement = append(extensionResourcesContainer, $('.resources'));933for (const [label, icon, uri] of resources) {934const resourceElement = append(resourcesElement, $('.resource'));935append(resourceElement, $(ThemeIcon.asCSSSelector(icon)));936append(resourceElement, $('a', { tabindex: '0' }, label));937this.disposables.add(onClick(resourceElement, () => this.openerService.open(uri)));938this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), resourceElement, uri.toString()));939}940}941}942943private renderInstallInfo(container: HTMLElement, extension: ILocalMcpServer): void {944const installInfoContainer = append(container, $('.more-info-container.additional-details-element'));945append(installInfoContainer, $('.additional-details-title', undefined, localize('Install Info', "Installation")));946const installInfo = append(installInfoContainer, $('.more-info'));947append(installInfo,948$('.more-info-entry', undefined,949$('div.more-info-entry-name', undefined, localize('id', "Identifier")),950$('code', undefined, extension.name)951));952if (extension.version) {953append(installInfo,954$('.more-info-entry', undefined,955$('div.more-info-entry-name', undefined, localize('Version', "Version")),956$('code', undefined, extension.version)957)958);959}960}961962private renderMarketplaceInfo(container: HTMLElement, extension: IWorkbenchMcpServer): void {963const gallery = extension.gallery;964const moreInfoContainer = append(container, $('.more-info-container.additional-details-element'));965append(moreInfoContainer, $('.additional-details-title', undefined, localize('Marketplace Info', "Marketplace")));966const moreInfo = append(moreInfoContainer, $('.more-info'));967if (gallery) {968if (!extension.local) {969append(moreInfo,970$('.more-info-entry', undefined,971$('div.more-info-entry-name', undefined, localize('id', "Identifier")),972$('code', undefined, extension.name)973));974if (gallery.version) {975append(moreInfo,976$('.more-info-entry', undefined,977$('div.more-info-entry-name', undefined, localize('Version', "Version")),978$('code', undefined, gallery.version)979)980);981}982}983if (gallery.lastUpdated) {984append(moreInfo,985$('.more-info-entry', undefined,986$('div.more-info-entry-name', undefined, localize('last updated', "Last Released")),987$('div', {988'title': new Date(gallery.lastUpdated).toString()989}, fromNow(gallery.lastUpdated, true, true, true))990)991);992}993if (gallery.publishDate) {994append(moreInfo,995$('.more-info-entry', undefined,996$('div.more-info-entry-name', undefined, localize('published', "Published")),997$('div', {998'title': new Date(gallery.publishDate).toString()999}, fromNow(gallery.publishDate, true, true, true))1000)1001);1002}1003}1004}1005}100610071008