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