Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts
5291 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './media/mcpServerEditor.css';
7
import { $, Dimension, append, clearNode, setParentFlowTo } from '../../../../base/browser/dom.js';
8
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
9
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
10
import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js';
11
import { Action, IAction } from '../../../../base/common/actions.js';
12
import * as arrays from '../../../../base/common/arrays.js';
13
import { Cache, CacheResult } from '../../../../base/common/cache.js';
14
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
15
import { isCancellationError } from '../../../../base/common/errors.js';
16
import { Emitter, Event } from '../../../../base/common/event.js';
17
import { Disposable, DisposableStore, MutableDisposable, dispose, toDisposable } from '../../../../base/common/lifecycle.js';
18
import { Schemas, matchesScheme } from '../../../../base/common/network.js';
19
import { URI } from '../../../../base/common/uri.js';
20
import { generateUuid } from '../../../../base/common/uuid.js';
21
import { TokenizationRegistry } from '../../../../editor/common/languages.js';
22
import { ILanguageService } from '../../../../editor/common/languages/language.js';
23
import { generateTokensCSSForColorMap } from '../../../../editor/common/languages/supports/tokenization.js';
24
import { localize } from '../../../../nls.js';
25
import { IContextKeyService, IScopedContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
26
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
27
import { INotificationService } from '../../../../platform/notification/common/notification.js';
28
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
29
import { IStorageService } from '../../../../platform/storage/common/storage.js';
30
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
31
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
32
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
33
import { IEditorOpenContext } from '../../../common/editor.js';
34
import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from '../../markdown/browser/markdownDocumentRenderer.js';
35
import { IWebview, IWebviewService } from '../../webview/browser/webview.js';
36
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
37
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
38
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
39
import { IMcpServerContainer, IMcpServerEditorOptions, IMcpWorkbenchService, IWorkbenchMcpServer, McpServerContainers, McpServerInstallState } from '../common/mcpTypes.js';
40
import { StarredWidget, McpServerIconWidget, McpServerStatusWidget, McpServerWidget, onClick, PublisherWidget, McpServerScopeBadgeWidget, LicenseWidget } from './mcpServerWidgets.js';
41
import { ButtonWithDropDownExtensionAction, ButtonWithDropdownExtensionActionViewItem, DropDownAction, InstallAction, InstallingLabelAction, InstallInRemoteAction, InstallInWorkspaceAction, ManageMcpServerAction, McpServerStatusAction, UninstallAction } from './mcpServerActions.js';
42
import { McpServerEditorInput } from './mcpServerEditorInput.js';
43
import { ILocalMcpServer, IGalleryMcpServerConfiguration, IMcpServerPackage, IMcpServerKeyValueInput, RegistryType } from '../../../../platform/mcp/common/mcpManagement.js';
44
import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
45
import { McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
46
import { ThemeIcon } from '../../../../base/common/themables.js';
47
import { Codicon } from '../../../../base/common/codicons.js';
48
import { getMcpGalleryManifestResourceUri, IMcpGalleryManifestService, McpGalleryResourceType } from '../../../../platform/mcp/common/mcpGalleryManifest.js';
49
import { fromNow } from '../../../../base/common/date.js';
50
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
51
52
const enum McpServerEditorTab {
53
Readme = 'readme',
54
Configuration = 'configuration',
55
Manifest = 'manifest',
56
}
57
58
class NavBar extends Disposable {
59
60
private _onChange = this._register(new Emitter<{ id: string | null; focus: boolean }>());
61
get onChange(): Event<{ id: string | null; focus: boolean }> { return this._onChange.event; }
62
63
private _currentId: string | null = null;
64
get currentId(): string | null { return this._currentId; }
65
66
private actions: Action[];
67
private actionbar: ActionBar;
68
69
constructor(container: HTMLElement) {
70
super();
71
const element = append(container, $('.navbar'));
72
this.actions = [];
73
this.actionbar = this._register(new ActionBar(element));
74
}
75
76
push(id: string, label: string, tooltip: string, index?: number): void {
77
const action = new Action(id, label, undefined, true, () => this.update(id, true));
78
79
action.tooltip = tooltip;
80
81
if (typeof index === 'number') {
82
this.actions.splice(index, 0, action);
83
} else {
84
this.actions.push(action);
85
}
86
this.actionbar.push(action, { index });
87
88
if (this.actions.length === 1) {
89
this.update(id);
90
}
91
}
92
93
remove(id: string): void {
94
const index = this.actions.findIndex(action => action.id === id);
95
if (index !== -1) {
96
this.actions.splice(index, 1);
97
this.actionbar.pull(index);
98
if (this._currentId === id) {
99
this.switch(this.actions[0]?.id);
100
}
101
}
102
}
103
104
clear(): void {
105
this.actions = dispose(this.actions);
106
this.actionbar.clear();
107
}
108
109
switch(id: string): boolean {
110
const action = this.actions.find(action => action.id === id);
111
if (action) {
112
action.run();
113
return true;
114
}
115
return false;
116
}
117
118
has(id: string): boolean {
119
return this.actions.some(action => action.id === id);
120
}
121
122
private update(id: string, focus?: boolean): void {
123
this._currentId = id;
124
this._onChange.fire({ id, focus: !!focus });
125
this.actions.forEach(a => a.checked = a.id === id);
126
}
127
}
128
129
interface ILayoutParticipant {
130
layout(): void;
131
}
132
133
interface IActiveElement {
134
focus(): void;
135
}
136
137
interface IExtensionEditorTemplate {
138
name: HTMLElement;
139
description: HTMLElement;
140
actionsAndStatusContainer: HTMLElement;
141
actionBar: ActionBar;
142
navbar: NavBar;
143
content: HTMLElement;
144
header: HTMLElement;
145
mcpServer: IWorkbenchMcpServer;
146
}
147
148
const enum WebviewIndex {
149
Readme,
150
Changelog
151
}
152
153
export class McpServerEditor extends EditorPane {
154
155
static readonly ID: string = 'workbench.editor.mcpServer';
156
157
private readonly _scopedContextKeyService = this._register(new MutableDisposable<IScopedContextKeyService>());
158
private template: IExtensionEditorTemplate | undefined;
159
160
private mcpServerReadme: Cache<string> | null;
161
private mcpServerManifest: Cache<IGalleryMcpServerConfiguration> | null;
162
163
// Some action bar items use a webview whose vertical scroll position we track in this map
164
private initialScrollProgress: Map<WebviewIndex, number> = new Map();
165
166
// Spot when an ExtensionEditor instance gets reused for a different extension, in which case the vertical scroll positions must be zeroed
167
private currentIdentifier: string = '';
168
169
private layoutParticipants: ILayoutParticipant[] = [];
170
private readonly contentDisposables = this._register(new DisposableStore());
171
private readonly transientDisposables = this._register(new DisposableStore());
172
private activeElement: IActiveElement | null = null;
173
private dimension: Dimension | undefined;
174
175
constructor(
176
group: IEditorGroup,
177
@ITelemetryService telemetryService: ITelemetryService,
178
@IInstantiationService private readonly instantiationService: IInstantiationService,
179
@IThemeService themeService: IThemeService,
180
@INotificationService private readonly notificationService: INotificationService,
181
@IOpenerService private readonly openerService: IOpenerService,
182
@IStorageService storageService: IStorageService,
183
@IExtensionService private readonly extensionService: IExtensionService,
184
@IWebviewService private readonly webviewService: IWebviewService,
185
@ILanguageService private readonly languageService: ILanguageService,
186
@IContextKeyService private readonly contextKeyService: IContextKeyService,
187
@IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService,
188
@IHoverService private readonly hoverService: IHoverService,
189
@IContextMenuService private readonly contextMenuService: IContextMenuService,
190
) {
191
super(McpServerEditor.ID, group, telemetryService, themeService, storageService);
192
this.mcpServerReadme = null;
193
this.mcpServerManifest = null;
194
}
195
196
override get scopedContextKeyService(): IContextKeyService | undefined {
197
return this._scopedContextKeyService.value;
198
}
199
200
protected createEditor(parent: HTMLElement): void {
201
const root = append(parent, $('.extension-editor.mcp-server-editor'));
202
this._scopedContextKeyService.value = this.contextKeyService.createScoped(root);
203
this._scopedContextKeyService.value.createKey('inExtensionEditor', true);
204
205
root.tabIndex = 0; // this is required for the focus tracker on the editor
206
root.style.outline = 'none';
207
root.setAttribute('role', 'document');
208
const header = append(root, $('.header'));
209
210
const iconContainer = append(header, $('.icon-container'));
211
const iconWidget = this.instantiationService.createInstance(McpServerIconWidget, iconContainer);
212
const scopeWidget = this.instantiationService.createInstance(McpServerScopeBadgeWidget, iconContainer);
213
214
const details = append(header, $('.details'));
215
const title = append(details, $('.title'));
216
const name = append(title, $('span.name.clickable', { role: 'heading', tabIndex: 0 }));
217
this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), name, localize('name', "Extension name")));
218
219
const subtitle = append(details, $('.subtitle'));
220
const subTitleEntryContainers: HTMLElement[] = [];
221
222
const publisherContainer = append(subtitle, $('.subtitle-entry'));
223
subTitleEntryContainers.push(publisherContainer);
224
const publisherWidget = this.instantiationService.createInstance(PublisherWidget, publisherContainer, false);
225
226
const starredContainer = append(subtitle, $('.subtitle-entry'));
227
subTitleEntryContainers.push(starredContainer);
228
const installCountWidget = this.instantiationService.createInstance(StarredWidget, starredContainer, false);
229
230
const licenseContainer = append(subtitle, $('.subtitle-entry'));
231
subTitleEntryContainers.push(licenseContainer);
232
const licenseWidget = this.instantiationService.createInstance(LicenseWidget, licenseContainer);
233
234
const widgets: McpServerWidget[] = [
235
iconWidget,
236
publisherWidget,
237
installCountWidget,
238
scopeWidget,
239
licenseWidget
240
];
241
242
const description = append(details, $('.description'));
243
244
const actions = [
245
this.instantiationService.createInstance(InstallAction, false),
246
this.instantiationService.createInstance(InstallingLabelAction),
247
this.instantiationService.createInstance(ButtonWithDropDownExtensionAction, 'extensions.uninstall', UninstallAction.CLASS, [
248
[
249
this.instantiationService.createInstance(UninstallAction),
250
this.instantiationService.createInstance(InstallInWorkspaceAction, false),
251
this.instantiationService.createInstance(InstallInRemoteAction, false)
252
]
253
]),
254
this.instantiationService.createInstance(ManageMcpServerAction, true),
255
];
256
257
const actionsAndStatusContainer = append(details, $('.actions-status-container.mcp-server-actions'));
258
const actionBar = this._register(new ActionBar(actionsAndStatusContainer, {
259
actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => {
260
if (action instanceof DropDownAction) {
261
return action.createActionViewItem(options);
262
}
263
if (action instanceof ButtonWithDropDownExtensionAction) {
264
return new ButtonWithDropdownExtensionActionViewItem(
265
action,
266
{
267
...options,
268
icon: true,
269
label: true,
270
menuActionsOrProvider: { getActions: () => action.menuActions },
271
menuActionClassNames: action.menuActionClassNames
272
},
273
this.contextMenuService);
274
}
275
return undefined;
276
},
277
focusOnlyEnabledItems: true
278
}));
279
280
actionBar.push(actions, { icon: true, label: true });
281
actionBar.setFocusable(true);
282
// update focusable elements when the enablement of an action changes
283
this._register(Event.any(...actions.map(a => Event.filter(a.onDidChange, e => e.enabled !== undefined)))(() => {
284
actionBar.setFocusable(false);
285
actionBar.setFocusable(true);
286
}));
287
288
const otherContainers: IMcpServerContainer[] = [];
289
const mcpServerStatusAction = this.instantiationService.createInstance(McpServerStatusAction);
290
const mcpServerStatusWidget = this._register(this.instantiationService.createInstance(McpServerStatusWidget, append(actionsAndStatusContainer, $('.status')), mcpServerStatusAction));
291
this._register(Event.any(mcpServerStatusWidget.onDidRender)(() => {
292
if (this.dimension) {
293
this.layout(this.dimension);
294
}
295
}));
296
297
otherContainers.push(mcpServerStatusAction, new class extends McpServerWidget {
298
render() {
299
actionsAndStatusContainer.classList.toggle('list-layout', this.mcpServer?.installState === McpServerInstallState.Installed);
300
}
301
}());
302
303
const mcpServerContainers: McpServerContainers = this.instantiationService.createInstance(McpServerContainers, [...actions, ...widgets, ...otherContainers]);
304
for (const disposable of [...actions, ...widgets, ...otherContainers, mcpServerContainers]) {
305
this._register(disposable);
306
}
307
308
const onError = Event.chain(actionBar.onDidRun, $ =>
309
$.map(({ error }) => error)
310
.filter(error => !!error)
311
);
312
313
this._register(onError(this.onError, this));
314
315
const body = append(root, $('.body'));
316
const navbar = new NavBar(body);
317
318
const content = append(body, $('.content'));
319
content.id = generateUuid(); // An id is needed for the webview parent flow to
320
321
this.template = {
322
content,
323
description,
324
header,
325
name,
326
navbar,
327
actionsAndStatusContainer,
328
actionBar: actionBar,
329
set mcpServer(mcpServer: IWorkbenchMcpServer) {
330
mcpServerContainers.mcpServer = mcpServer;
331
let lastNonEmptySubtitleEntryContainer;
332
for (const subTitleEntryElement of subTitleEntryContainers) {
333
subTitleEntryElement.classList.remove('last-non-empty');
334
if (subTitleEntryElement.children.length > 0) {
335
lastNonEmptySubtitleEntryContainer = subTitleEntryElement;
336
}
337
}
338
if (lastNonEmptySubtitleEntryContainer) {
339
lastNonEmptySubtitleEntryContainer.classList.add('last-non-empty');
340
}
341
}
342
};
343
}
344
345
override async setInput(input: McpServerEditorInput, options: IMcpServerEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
346
await super.setInput(input, options, context, token);
347
if (this.template) {
348
await this.render(input.mcpServer, this.template, !!options?.preserveFocus);
349
}
350
}
351
352
private async render(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, preserveFocus: boolean): Promise<void> {
353
this.activeElement = null;
354
this.transientDisposables.clear();
355
356
const token = this.transientDisposables.add(new CancellationTokenSource()).token;
357
358
this.mcpServerReadme = new Cache(() => mcpServer.getReadme(token));
359
this.mcpServerManifest = new Cache(() => mcpServer.getManifest(token));
360
template.mcpServer = mcpServer;
361
362
template.name.textContent = mcpServer.label;
363
template.name.classList.toggle('clickable', !!mcpServer.gallery?.webUrl);
364
template.description.textContent = mcpServer.description;
365
if (mcpServer.gallery?.webUrl) {
366
this.transientDisposables.add(onClick(template.name, () => this.openerService.open(URI.parse(mcpServer.gallery?.webUrl!))));
367
}
368
369
this.renderNavbar(mcpServer, template, preserveFocus);
370
}
371
372
override setOptions(options: IMcpServerEditorOptions | undefined): void {
373
super.setOptions(options);
374
if (options?.tab) {
375
this.template?.navbar.switch(options.tab);
376
}
377
}
378
379
private renderNavbar(extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, preserveFocus: boolean): void {
380
template.content.innerText = '';
381
template.navbar.clear();
382
383
if (this.currentIdentifier !== extension.id) {
384
this.initialScrollProgress.clear();
385
this.currentIdentifier = extension.id;
386
}
387
388
if (extension.readmeUrl || extension.gallery?.readme) {
389
template.navbar.push(McpServerEditorTab.Readme, localize('details', "Details"), localize('detailstooltip', "Extension details, rendered from the extension's 'README.md' file"));
390
}
391
392
if (extension.gallery || extension.local?.manifest) {
393
template.navbar.push(McpServerEditorTab.Manifest, localize('manifest', "Manifest"), localize('manifesttooltip', "Server manifest details"));
394
}
395
396
if (extension.config) {
397
template.navbar.push(McpServerEditorTab.Configuration, localize('configuration', "Configuration"), localize('configurationtooltip', "Server configuration details"));
398
}
399
400
this.transientDisposables.add(this.mcpWorkbenchService.onChange(e => {
401
if (e === extension) {
402
if (e.config && !template.navbar.has(McpServerEditorTab.Configuration)) {
403
template.navbar.push(McpServerEditorTab.Configuration, localize('configuration', "Configuration"), localize('configurationtooltip', "Server configuration details"), extension.readmeUrl ? 1 : 0);
404
}
405
if (!e.config && template.navbar.has(McpServerEditorTab.Configuration)) {
406
template.navbar.remove(McpServerEditorTab.Configuration);
407
}
408
}
409
}));
410
411
if ((<IMcpServerEditorOptions | undefined>this.options)?.tab) {
412
template.navbar.switch((<IMcpServerEditorOptions>this.options).tab!);
413
}
414
415
if (template.navbar.currentId) {
416
this.onNavbarChange(extension, { id: template.navbar.currentId, focus: !preserveFocus }, template);
417
}
418
template.navbar.onChange(e => this.onNavbarChange(extension, e, template), this, this.transientDisposables);
419
}
420
421
override clearInput(): void {
422
this.contentDisposables.clear();
423
this.transientDisposables.clear();
424
425
super.clearInput();
426
}
427
428
override focus(): void {
429
super.focus();
430
this.activeElement?.focus();
431
}
432
433
showFind(): void {
434
this.activeWebview?.showFind();
435
}
436
437
runFindAction(previous: boolean): void {
438
this.activeWebview?.runFindAction(previous);
439
}
440
441
public get activeWebview(): IWebview | undefined {
442
if (!this.activeElement || !(this.activeElement as IWebview).runFindAction) {
443
return undefined;
444
}
445
return this.activeElement as IWebview;
446
}
447
448
private onNavbarChange(extension: IWorkbenchMcpServer, { id, focus }: { id: string | null; focus: boolean }, template: IExtensionEditorTemplate): void {
449
this.contentDisposables.clear();
450
template.content.innerText = '';
451
this.activeElement = null;
452
if (id) {
453
const cts = new CancellationTokenSource();
454
this.contentDisposables.add(toDisposable(() => cts.dispose(true)));
455
this.open(id, extension, template, cts.token)
456
.then(activeElement => {
457
if (cts.token.isCancellationRequested) {
458
return;
459
}
460
this.activeElement = activeElement;
461
if (focus) {
462
this.focus();
463
}
464
});
465
}
466
}
467
468
private open(id: string, extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {
469
switch (id) {
470
case McpServerEditorTab.Configuration: return this.openConfiguration(extension, template, token);
471
case McpServerEditorTab.Readme: return this.openDetails(extension, template, token);
472
case McpServerEditorTab.Manifest: return extension.readmeUrl ? this.openManifest(extension, template.content, token) : this.openManifestWithAdditionalDetails(extension, template, token);
473
}
474
return Promise.resolve(null);
475
}
476
477
private async openMarkdown(extension: IWorkbenchMcpServer, cacheResult: CacheResult<string>, noContentCopy: string, container: HTMLElement, webviewIndex: WebviewIndex, title: string, token: CancellationToken): Promise<IActiveElement | null> {
478
try {
479
const body = await this.renderMarkdown(extension, cacheResult, container, token);
480
if (token.isCancellationRequested) {
481
return Promise.resolve(null);
482
}
483
484
const webview = this.contentDisposables.add(this.webviewService.createWebviewOverlay({
485
title,
486
options: {
487
enableFindWidget: true,
488
tryRestoreScrollPosition: true,
489
disableServiceWorker: true,
490
},
491
contentOptions: {},
492
extension: undefined,
493
}));
494
495
webview.initialScrollProgress = this.initialScrollProgress.get(webviewIndex) || 0;
496
497
webview.claim(this, this.window, this.scopedContextKeyService);
498
setParentFlowTo(webview.container, container);
499
webview.layoutWebviewOverElement(container);
500
501
webview.setHtml(body);
502
webview.claim(this, this.window, undefined);
503
504
this.contentDisposables.add(webview.onDidFocus(() => this._onDidFocus?.fire()));
505
506
this.contentDisposables.add(webview.onDidScroll(() => this.initialScrollProgress.set(webviewIndex, webview.initialScrollProgress)));
507
508
const removeLayoutParticipant = arrays.insert(this.layoutParticipants, {
509
layout: () => {
510
webview.layoutWebviewOverElement(container);
511
}
512
});
513
this.contentDisposables.add(toDisposable(removeLayoutParticipant));
514
515
let isDisposed = false;
516
this.contentDisposables.add(toDisposable(() => { isDisposed = true; }));
517
518
this.contentDisposables.add(this.themeService.onDidColorThemeChange(async () => {
519
// Render again since syntax highlighting of code blocks may have changed
520
const body = await this.renderMarkdown(extension, cacheResult, container);
521
if (!isDisposed) { // Make sure we weren't disposed of in the meantime
522
webview.setHtml(body);
523
}
524
}));
525
526
this.contentDisposables.add(webview.onDidClickLink(link => {
527
if (!link) {
528
return;
529
}
530
// Only allow links with specific schemes
531
if (matchesScheme(link, Schemas.http) || matchesScheme(link, Schemas.https) || matchesScheme(link, Schemas.mailto)) {
532
this.openerService.open(link);
533
}
534
}));
535
536
return webview;
537
} catch (e) {
538
const p = append(container, $('p.nocontent'));
539
p.textContent = noContentCopy;
540
return p;
541
}
542
}
543
544
private async renderMarkdown(extension: IWorkbenchMcpServer, cacheResult: CacheResult<string>, container: HTMLElement, token?: CancellationToken): Promise<string> {
545
const contents = await this.loadContents(() => cacheResult, container);
546
if (token?.isCancellationRequested) {
547
return '';
548
}
549
550
const content = await renderMarkdownDocument(contents, this.extensionService, this.languageService, {}, token);
551
if (token?.isCancellationRequested) {
552
return '';
553
}
554
555
return this.renderBody(content);
556
}
557
558
private renderBody(body: TrustedHTML): string {
559
const nonce = generateUuid();
560
const colorMap = TokenizationRegistry.getColorMap();
561
const css = colorMap ? generateTokensCSSForColorMap(colorMap) : '';
562
return `<!DOCTYPE html>
563
<html>
564
<head>
565
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
566
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: data:; media-src https:; script-src 'none'; style-src 'nonce-${nonce}';">
567
<style nonce="${nonce}">
568
${DEFAULT_MARKDOWN_STYLES}
569
570
/* prevent scroll-to-top button from blocking the body text */
571
body {
572
padding-bottom: 75px;
573
}
574
575
#scroll-to-top {
576
position: fixed;
577
width: 32px;
578
height: 32px;
579
right: 25px;
580
bottom: 25px;
581
background-color: var(--vscode-button-secondaryBackground);
582
border-color: var(--vscode-button-border);
583
border-radius: 50%;
584
cursor: pointer;
585
box-shadow: 1px 1px 1px rgba(0,0,0,.25);
586
outline: none;
587
display: flex;
588
justify-content: center;
589
align-items: center;
590
}
591
592
#scroll-to-top:hover {
593
background-color: var(--vscode-button-secondaryHoverBackground);
594
box-shadow: 2px 2px 2px rgba(0,0,0,.25);
595
}
596
597
body.vscode-high-contrast #scroll-to-top {
598
border-width: 2px;
599
border-style: solid;
600
box-shadow: none;
601
}
602
603
#scroll-to-top span.icon::before {
604
content: "";
605
display: block;
606
background: var(--vscode-button-secondaryForeground);
607
/* Chevron up icon */
608
webkit-mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAxNiAxNiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTYgMTY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkZGRkZGO30KCS5zdDF7ZmlsbDpub25lO30KPC9zdHlsZT4KPHRpdGxlPnVwY2hldnJvbjwvdGl0bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik04LDUuMWwtNy4zLDcuM0wwLDExLjZsOC04bDgsOGwtMC43LDAuN0w4LDUuMXoiLz4KPHJlY3QgY2xhc3M9InN0MSIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2Ii8+Cjwvc3ZnPgo=');
609
-webkit-mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAxNiAxNiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTYgMTY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkZGRkZGO30KCS5zdDF7ZmlsbDpub25lO30KPC9zdHlsZT4KPHRpdGxlPnVwY2hldnJvbjwvdGl0bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik04LDUuMWwtNy4zLDcuM0wwLDExLjZsOC04bDgsOGwtMC43LDAuN0w4LDUuMXoiLz4KPHJlY3QgY2xhc3M9InN0MSIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2Ii8+Cjwvc3ZnPgo=');
610
width: 16px;
611
height: 16px;
612
}
613
${css}
614
</style>
615
</head>
616
<body>
617
<a id="scroll-to-top" role="button" aria-label="scroll to top" href="#"><span class="icon"></span></a>
618
${body}
619
</body>
620
</html>`;
621
}
622
623
private async openDetails(extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {
624
const details = append(template.content, $('.details'));
625
const readmeContainer = append(details, $('.content-container'));
626
const additionalDetailsContainer = append(details, $('.additional-details-container'));
627
628
const layout = () => details.classList.toggle('narrow', this.dimension && this.dimension.width < 500);
629
layout();
630
this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));
631
632
const activeElement = await this.openMarkdown(extension, this.mcpServerReadme!.get(), localize('noReadme', "No README available."), readmeContainer, WebviewIndex.Readme, localize('Readme title', "Readme"), token);
633
this.renderAdditionalDetails(additionalDetailsContainer, extension);
634
return activeElement;
635
}
636
637
private async openConfiguration(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {
638
const configContainer = append(template.content, $('.configuration'));
639
const content = $('div', { class: 'configuration-content' });
640
641
this.renderConfigurationDetails(content, mcpServer);
642
643
const scrollableContent = new DomScrollableElement(content, {});
644
const layout = () => scrollableContent.scanDomNode();
645
this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));
646
647
append(configContainer, scrollableContent.getDomNode());
648
649
return { focus: () => content.focus() };
650
}
651
652
private async openManifestWithAdditionalDetails(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {
653
const details = append(template.content, $('.details'));
654
655
const readmeContainer = append(details, $('.content-container'));
656
const additionalDetailsContainer = append(details, $('.additional-details-container'));
657
658
const layout = () => details.classList.toggle('narrow', this.dimension && this.dimension.width < 500);
659
layout();
660
this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));
661
662
const activeElement = await this.openManifest(mcpServer, readmeContainer, token);
663
664
this.renderAdditionalDetails(additionalDetailsContainer, mcpServer);
665
return activeElement;
666
}
667
668
private async openManifest(mcpServer: IWorkbenchMcpServer, parent: HTMLElement, token: CancellationToken): Promise<IActiveElement | null> {
669
const manifestContainer = append(parent, $('.manifest'));
670
const content = $('div', { class: 'manifest-content' });
671
672
try {
673
const manifest = await this.loadContents(() => this.mcpServerManifest!.get(), content);
674
if (token.isCancellationRequested) {
675
return null;
676
}
677
this.renderManifestDetails(content, manifest);
678
} catch (error) {
679
// Handle error - show no manifest message
680
while (content.firstChild) {
681
content.removeChild(content.firstChild);
682
}
683
const noManifestMessage = append(content, $('.no-manifest'));
684
noManifestMessage.textContent = localize('noManifest', "No manifest available for this MCP server.");
685
}
686
687
const scrollableContent = new DomScrollableElement(content, {});
688
const layout = () => scrollableContent.scanDomNode();
689
this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));
690
691
append(manifestContainer, scrollableContent.getDomNode());
692
693
return { focus: () => content.focus() };
694
}
695
696
private renderConfigurationDetails(container: HTMLElement, mcpServer: IWorkbenchMcpServer): void {
697
clearNode(container);
698
699
const config = mcpServer.config;
700
701
if (!config) {
702
const noConfigMessage = append(container, $('.no-config'));
703
noConfigMessage.textContent = localize('noConfig', "No configuration available for this MCP server.");
704
return;
705
}
706
707
// Server Name
708
const nameSection = append(container, $('.config-section'));
709
const nameLabel = append(nameSection, $('.config-label'));
710
nameLabel.textContent = localize('serverName', "Name:");
711
const nameValue = append(nameSection, $('.config-value'));
712
nameValue.textContent = mcpServer.name;
713
714
// Server Type
715
const typeSection = append(container, $('.config-section'));
716
const typeLabel = append(typeSection, $('.config-label'));
717
typeLabel.textContent = localize('serverType', "Type:");
718
const typeValue = append(typeSection, $('.config-value'));
719
typeValue.textContent = config.type;
720
721
// Type-specific configuration
722
if (config.type === McpServerType.LOCAL) {
723
// Command
724
const commandSection = append(container, $('.config-section'));
725
const commandLabel = append(commandSection, $('.config-label'));
726
commandLabel.textContent = localize('command', "Command:");
727
const commandValue = append(commandSection, $('code.config-value'));
728
commandValue.textContent = config.command;
729
730
// Arguments (if present)
731
if (config.args && config.args.length > 0) {
732
const argsSection = append(container, $('.config-section'));
733
const argsLabel = append(argsSection, $('.config-label'));
734
argsLabel.textContent = localize('arguments', "Arguments:");
735
const argsValue = append(argsSection, $('code.config-value'));
736
argsValue.textContent = config.args.join(' ');
737
}
738
} else if (config.type === McpServerType.REMOTE) {
739
// URL
740
const urlSection = append(container, $('.config-section'));
741
const urlLabel = append(urlSection, $('.config-label'));
742
urlLabel.textContent = localize('url', "URL:");
743
const urlValue = append(urlSection, $('code.config-value'));
744
urlValue.textContent = config.url;
745
}
746
}
747
748
private renderManifestDetails(container: HTMLElement, manifest: IGalleryMcpServerConfiguration): void {
749
clearNode(container);
750
751
if (manifest.packages && manifest.packages.length > 0) {
752
const packagesByType = new Map<RegistryType, IMcpServerPackage[]>();
753
for (const pkg of manifest.packages) {
754
const type = pkg.registryType;
755
let packages = packagesByType.get(type);
756
if (!packages) {
757
packagesByType.set(type, packages = []);
758
}
759
packages.push(pkg);
760
}
761
762
append(container, $('.manifest-section', undefined, $('.manifest-section-title', undefined, localize('packages', "Packages"))));
763
764
for (const [packageType, packages] of packagesByType) {
765
const packageSection = append(container, $('.package-section', undefined, $('.package-section-title', undefined, packageType.toUpperCase())));
766
const packagesGrid = append(packageSection, $('.package-details'));
767
768
for (let i = 0; i < packages.length; i++) {
769
const pkg = packages[i];
770
append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('packageName', "Package:")), $('.detail-value', undefined, pkg.identifier)));
771
if (pkg.packageArguments && pkg.packageArguments.length > 0) {
772
const argStrings: string[] = [];
773
for (const arg of pkg.packageArguments) {
774
if (arg.type === 'named') {
775
argStrings.push(arg.name);
776
if (arg.value) {
777
argStrings.push(arg.value);
778
}
779
}
780
if (arg.type === 'positional') {
781
const val = arg.value ?? arg.valueHint;
782
if (val) {
783
argStrings.push(val);
784
}
785
}
786
}
787
append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('packagearguments', "Package Arguments:")), $('code.detail-value', undefined, argStrings.join(' '))));
788
}
789
if (pkg.runtimeArguments && pkg.runtimeArguments.length > 0) {
790
const argStrings: string[] = [];
791
for (const arg of pkg.runtimeArguments) {
792
if (arg.type === 'named') {
793
argStrings.push(arg.name);
794
if (arg.value) {
795
argStrings.push(arg.value);
796
}
797
}
798
if (arg.type === 'positional') {
799
const val = arg.value ?? arg.valueHint;
800
if (val) {
801
argStrings.push(val);
802
}
803
}
804
}
805
append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('runtimeargs', "Runtime Arguments:")), $('code.detail-value', undefined, argStrings.join(' '))));
806
}
807
if (pkg.environmentVariables && pkg.environmentVariables.length > 0) {
808
const envStrings = pkg.environmentVariables.map((envVar: IMcpServerKeyValueInput) => `${envVar.name}=${envVar.value ?? ''}`);
809
append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('environmentVariables', "Environment Variables:")), $('code.detail-value', undefined, envStrings.join(' '))));
810
}
811
if (i < packages.length - 1) {
812
append(packagesGrid, $('.package-separator'));
813
}
814
}
815
}
816
}
817
818
if (manifest.remotes && manifest.remotes.length > 0) {
819
const packageSection = append(container, $('.package-section', undefined, $('.package-section-title', undefined, localize('remotes', "Remote").toLocaleUpperCase())));
820
for (const remote of manifest.remotes) {
821
const packagesGrid = append(packageSection, $('.package-details'));
822
append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('url', "URL:")), $('.detail-value', undefined, remote.url)));
823
if (remote.type) {
824
append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('transport', "Transport:")), $('.detail-value', undefined, remote.type)));
825
}
826
if (remote.headers && remote.headers.length > 0) {
827
const headerStrings = remote.headers.map((header: IMcpServerKeyValueInput) => `${header.name}: ${header.value ?? ''}`);
828
append(packagesGrid, $('.package-detail', undefined, $('.detail-label', undefined, localize('headers', "Headers:")), $('.detail-value', undefined, headerStrings.join(', '))));
829
}
830
}
831
}
832
}
833
834
private renderAdditionalDetails(container: HTMLElement, extension: IWorkbenchMcpServer): void {
835
const content = $('div', { class: 'additional-details-content', tabindex: '0' });
836
const scrollableContent = new DomScrollableElement(content, {});
837
const layout = () => scrollableContent.scanDomNode();
838
const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { layout });
839
this.contentDisposables.add(toDisposable(removeLayoutParticipant));
840
this.contentDisposables.add(scrollableContent);
841
842
this.contentDisposables.add(this.instantiationService.createInstance(AdditionalDetailsWidget, content, extension));
843
844
append(container, scrollableContent.getDomNode());
845
scrollableContent.scanDomNode();
846
}
847
848
private loadContents<T>(loadingTask: () => CacheResult<T>, container: HTMLElement): Promise<T> {
849
container.classList.add('loading');
850
851
const result = this.contentDisposables.add(loadingTask());
852
const onDone = () => container.classList.remove('loading');
853
result.promise.then(onDone, onDone);
854
855
return result.promise;
856
}
857
858
layout(dimension: Dimension): void {
859
this.dimension = dimension;
860
this.layoutParticipants.forEach(p => p.layout());
861
}
862
863
private onError(err: Error): void {
864
if (isCancellationError(err)) {
865
return;
866
}
867
868
this.notificationService.error(err);
869
}
870
}
871
872
class AdditionalDetailsWidget extends Disposable {
873
874
private readonly disposables = this._register(new DisposableStore());
875
876
constructor(
877
private readonly container: HTMLElement,
878
extension: IWorkbenchMcpServer,
879
@IMcpGalleryManifestService private readonly mcpGalleryManifestService: IMcpGalleryManifestService,
880
@IHoverService private readonly hoverService: IHoverService,
881
@IOpenerService private readonly openerService: IOpenerService,
882
) {
883
super();
884
this.render(extension);
885
this._register(this.mcpGalleryManifestService.onDidChangeMcpGalleryManifest(() => this.render(extension)));
886
}
887
888
private render(extension: IWorkbenchMcpServer): void {
889
this.container.innerText = '';
890
this.disposables.clear();
891
892
if (extension.local) {
893
this.renderInstallInfo(this.container, extension.local);
894
}
895
896
if (extension.gallery) {
897
this.renderMarketplaceInfo(this.container, extension);
898
}
899
this.renderTags(this.container, extension);
900
this.renderExtensionResources(this.container, extension);
901
}
902
903
private renderTags(container: HTMLElement, extension: IWorkbenchMcpServer): void {
904
if (extension.gallery?.topics?.length) {
905
const categoriesContainer = append(container, $('.categories-container.additional-details-element'));
906
append(categoriesContainer, $('.additional-details-title', undefined, localize('tags', "Tags")));
907
const categoriesElement = append(categoriesContainer, $('.categories'));
908
for (const category of extension.gallery.topics) {
909
append(categoriesElement, $('span.category', { tabindex: '0' }, category));
910
}
911
}
912
}
913
914
private async renderExtensionResources(container: HTMLElement, extension: IWorkbenchMcpServer): Promise<void> {
915
const resources: [string, ThemeIcon, URI][] = [];
916
const manifest = await this.mcpGalleryManifestService.getMcpGalleryManifest();
917
if (extension.repository) {
918
try {
919
resources.push([localize('repository', "Repository"), ThemeIcon.fromId(Codicon.repo.id), URI.parse(extension.repository)]);
920
} catch (error) {/* Ignore */ }
921
}
922
if (manifest) {
923
const supportUri = getMcpGalleryManifestResourceUri(manifest, McpGalleryResourceType.ContactSupportUri);
924
if (supportUri) {
925
try {
926
resources.push([localize('support', "Contact Support"), ThemeIcon.fromId(Codicon.commentDiscussion.id), URI.parse(supportUri)]);
927
} catch (error) {/* Ignore */ }
928
}
929
}
930
if (resources.length) {
931
const extensionResourcesContainer = append(container, $('.resources-container.additional-details-element'));
932
append(extensionResourcesContainer, $('.additional-details-title', undefined, localize('resources', "Resources")));
933
const resourcesElement = append(extensionResourcesContainer, $('.resources'));
934
for (const [label, icon, uri] of resources) {
935
const resourceElement = append(resourcesElement, $('.resource'));
936
append(resourceElement, $(ThemeIcon.asCSSSelector(icon)));
937
append(resourceElement, $('a', { tabindex: '0' }, label));
938
this.disposables.add(onClick(resourceElement, () => this.openerService.open(uri)));
939
this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), resourceElement, uri.toString()));
940
}
941
}
942
}
943
944
private renderInstallInfo(container: HTMLElement, extension: ILocalMcpServer): void {
945
const installInfoContainer = append(container, $('.more-info-container.additional-details-element'));
946
append(installInfoContainer, $('.additional-details-title', undefined, localize('Install Info', "Installation")));
947
const installInfo = append(installInfoContainer, $('.more-info'));
948
append(installInfo,
949
$('.more-info-entry', undefined,
950
$('div.more-info-entry-name', undefined, localize('id', "Identifier")),
951
$('code', undefined, extension.name)
952
));
953
if (extension.version) {
954
append(installInfo,
955
$('.more-info-entry', undefined,
956
$('div.more-info-entry-name', undefined, localize('Version', "Version")),
957
$('code', undefined, extension.version)
958
)
959
);
960
}
961
}
962
963
private renderMarketplaceInfo(container: HTMLElement, extension: IWorkbenchMcpServer): void {
964
const gallery = extension.gallery;
965
const moreInfoContainer = append(container, $('.more-info-container.additional-details-element'));
966
append(moreInfoContainer, $('.additional-details-title', undefined, localize('Marketplace Info', "Marketplace")));
967
const moreInfo = append(moreInfoContainer, $('.more-info'));
968
if (gallery) {
969
if (!extension.local) {
970
append(moreInfo,
971
$('.more-info-entry', undefined,
972
$('div.more-info-entry-name', undefined, localize('id', "Identifier")),
973
$('code', undefined, extension.name)
974
));
975
if (gallery.version) {
976
append(moreInfo,
977
$('.more-info-entry', undefined,
978
$('div.more-info-entry-name', undefined, localize('Version', "Version")),
979
$('code', undefined, gallery.version)
980
)
981
);
982
}
983
}
984
if (gallery.lastUpdated) {
985
append(moreInfo,
986
$('.more-info-entry', undefined,
987
$('div.more-info-entry-name', undefined, localize('last updated', "Last Released")),
988
$('div', {
989
'title': new Date(gallery.lastUpdated).toString()
990
}, fromNow(gallery.lastUpdated, true, true, true))
991
)
992
);
993
}
994
if (gallery.publishDate) {
995
append(moreInfo,
996
$('.more-info-entry', undefined,
997
$('div.more-info-entry-name', undefined, localize('published', "Published")),
998
$('div', {
999
'title': new Date(gallery.publishDate).toString()
1000
}, fromNow(gallery.publishDate, true, true, true))
1001
)
1002
);
1003
}
1004
}
1005
}
1006
}
1007
1008