Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpServerWidgets.ts
5272 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 * as dom from '../../../../base/browser/dom.js';
7
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
8
import { IManagedHover } from '../../../../base/browser/ui/hover/hover.js';
9
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
10
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
11
import { KeyCode } from '../../../../base/common/keyCodes.js';
12
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
13
import { ThemeIcon } from '../../../../base/common/themables.js';
14
import { URI } from '../../../../base/common/uri.js';
15
import { localize } from '../../../../nls.js';
16
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
17
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
18
import { verifiedPublisherIcon } from '../../../services/extensionManagement/common/extensionsIcons.js';
19
import { IMcpServerContainer, IWorkbenchMcpServer, McpServerInstallState } from '../common/mcpTypes.js';
20
import { IThemeService, registerThemingParticipant } from '../../../../platform/theme/common/themeService.js';
21
import { isDark } from '../../../../platform/theme/common/theme.js';
22
import { Emitter, Event } from '../../../../base/common/event.js';
23
import { McpServerStatusAction } from './mcpServerActions.js';
24
import { reset } from '../../../../base/browser/dom.js';
25
import { mcpLicenseIcon, mcpServerIcon, mcpServerRemoteIcon, mcpServerWorkspaceIcon, mcpStarredIcon } from './mcpServerIcons.js';
26
import { MarkdownString } from '../../../../base/common/htmlContent.js';
27
import { ExtensionHoverOptions, ExtensionIconBadge } from '../../extensions/browser/extensionsWidgets.js';
28
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
29
import { LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js';
30
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
31
import { registerColor } from '../../../../platform/theme/common/colorUtils.js';
32
import { textLinkForeground } from '../../../../platform/theme/common/colorRegistry.js';
33
import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';
34
35
export abstract class McpServerWidget extends Disposable implements IMcpServerContainer {
36
private _mcpServer: IWorkbenchMcpServer | null = null;
37
get mcpServer(): IWorkbenchMcpServer | null { return this._mcpServer; }
38
set mcpServer(mcpServer: IWorkbenchMcpServer | null) { this._mcpServer = mcpServer; this.update(); }
39
update(): void { this.render(); }
40
abstract render(): void;
41
}
42
43
export function onClick(element: HTMLElement, callback: () => void): IDisposable {
44
const disposables: DisposableStore = new DisposableStore();
45
disposables.add(dom.addDisposableListener(element, dom.EventType.CLICK, dom.finalHandler(callback)));
46
disposables.add(dom.addDisposableListener(element, dom.EventType.KEY_UP, e => {
47
const keyboardEvent = new StandardKeyboardEvent(e);
48
if (keyboardEvent.equals(KeyCode.Space) || keyboardEvent.equals(KeyCode.Enter)) {
49
e.preventDefault();
50
e.stopPropagation();
51
callback();
52
}
53
}));
54
return disposables;
55
}
56
57
export class McpServerIconWidget extends McpServerWidget {
58
59
private readonly iconLoadingDisposable = this._register(new MutableDisposable());
60
private readonly element: HTMLElement;
61
private readonly iconElement: HTMLImageElement;
62
private readonly codiconIconElement: HTMLElement;
63
64
private iconUrl: string | undefined;
65
66
constructor(
67
container: HTMLElement,
68
@IThemeService private readonly themeService: IThemeService
69
) {
70
super();
71
this.element = dom.append(container, dom.$('.extension-icon'));
72
73
this.iconElement = dom.append(this.element, dom.$('img.icon', { alt: '' }));
74
this.iconElement.style.display = 'none';
75
76
this.codiconIconElement = dom.append(this.element, dom.$(ThemeIcon.asCSSSelector(mcpServerIcon)));
77
this.codiconIconElement.style.display = 'none';
78
79
this.render();
80
this._register(toDisposable(() => this.clear()));
81
this._register(this.themeService.onDidColorThemeChange(() => this.render()));
82
}
83
84
private clear(): void {
85
this.iconUrl = undefined;
86
this.iconElement.src = '';
87
this.iconElement.style.display = 'none';
88
this.codiconIconElement.style.display = 'none';
89
this.codiconIconElement.className = ThemeIcon.asClassName(mcpServerIcon);
90
this.iconLoadingDisposable.clear();
91
}
92
93
render(): void {
94
if (!this.mcpServer) {
95
this.clear();
96
return;
97
}
98
99
if (this.mcpServer.icon) {
100
const type = this.themeService.getColorTheme().type;
101
const iconUrl = isDark(type) ? this.mcpServer.icon.dark : this.mcpServer.icon.light;
102
if (this.iconUrl !== iconUrl) {
103
this.iconElement.style.display = 'inherit';
104
this.codiconIconElement.style.display = 'none';
105
this.iconUrl = iconUrl;
106
this.iconLoadingDisposable.value = dom.addDisposableListener(this.iconElement, 'error', () => {
107
this.iconElement.style.display = 'none';
108
this.codiconIconElement.style.display = 'inherit';
109
}, { once: true });
110
this.iconElement.src = this.iconUrl;
111
if (!this.iconElement.complete) {
112
this.iconElement.style.visibility = 'hidden';
113
this.iconElement.onload = () => this.iconElement.style.visibility = 'inherit';
114
} else {
115
this.iconElement.style.visibility = 'inherit';
116
}
117
}
118
} else {
119
this.iconUrl = undefined;
120
this.iconElement.style.display = 'none';
121
this.iconElement.src = '';
122
this.codiconIconElement.className = this.mcpServer.codicon ? `codicon ${this.mcpServer.codicon}` : ThemeIcon.asClassName(mcpServerIcon);
123
this.codiconIconElement.style.display = 'inherit';
124
this.iconLoadingDisposable.clear();
125
}
126
}
127
}
128
129
export class PublisherWidget extends McpServerWidget {
130
131
private element: HTMLElement | undefined;
132
private containerHover: IManagedHover | undefined;
133
134
private readonly disposables = this._register(new DisposableStore());
135
136
constructor(
137
readonly container: HTMLElement,
138
private small: boolean,
139
@IHoverService private readonly hoverService: IHoverService,
140
@IOpenerService private readonly openerService: IOpenerService,
141
) {
142
super();
143
144
this.render();
145
this._register(toDisposable(() => this.clear()));
146
}
147
148
private clear(): void {
149
this.element?.remove();
150
this.disposables.clear();
151
}
152
153
render(): void {
154
this.clear();
155
if (!this.mcpServer?.publisherDisplayName) {
156
return;
157
}
158
159
this.element = dom.append(this.container, dom.$('.publisher'));
160
const publisherDisplayName = dom.$('.publisher-name.ellipsis');
161
publisherDisplayName.textContent = this.mcpServer.publisherDisplayName;
162
163
const verifiedPublisher = dom.$('.verified-publisher');
164
dom.append(verifiedPublisher, dom.$('span.extension-verified-publisher.clickable'), renderIcon(verifiedPublisherIcon));
165
166
if (this.small) {
167
if (this.mcpServer.gallery?.publisherDomain?.verified) {
168
dom.append(this.element, verifiedPublisher);
169
}
170
dom.append(this.element, publisherDisplayName);
171
} else {
172
this.element.classList.toggle('clickable', !!this.mcpServer.gallery?.publisherUrl);
173
this.element.setAttribute('role', 'button');
174
this.element.tabIndex = 0;
175
176
this.containerHover = this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.element, localize('publisher', "Publisher ({0})", this.mcpServer.publisherDisplayName)));
177
dom.append(this.element, publisherDisplayName);
178
179
if (this.mcpServer.gallery?.publisherDomain?.verified) {
180
dom.append(this.element, verifiedPublisher);
181
const publisherDomainLink = URI.parse(this.mcpServer.gallery?.publisherDomain.link);
182
verifiedPublisher.tabIndex = 0;
183
verifiedPublisher.setAttribute('role', 'button');
184
this.containerHover.update(localize('verified publisher', "This publisher has verified ownership of {0}", this.mcpServer.gallery?.publisherDomain.link));
185
verifiedPublisher.setAttribute('role', 'link');
186
187
dom.append(verifiedPublisher, dom.$('span.extension-verified-publisher-domain', undefined, publisherDomainLink.authority.startsWith('www.') ? publisherDomainLink.authority.substring(4) : publisherDomainLink.authority));
188
this.disposables.add(onClick(verifiedPublisher, () => this.openerService.open(publisherDomainLink)));
189
}
190
191
if (this.mcpServer.gallery?.publisherUrl) {
192
this.disposables.add(onClick(this.element, () => this.openerService.open(this.mcpServer?.gallery?.publisherUrl!)));
193
}
194
}
195
196
}
197
198
}
199
200
export class StarredWidget extends McpServerWidget {
201
202
private readonly disposables = this._register(new DisposableStore());
203
204
constructor(
205
readonly container: HTMLElement,
206
private small: boolean,
207
) {
208
super();
209
this.container.classList.add('extension-ratings');
210
if (this.small) {
211
container.classList.add('small');
212
}
213
214
this.render();
215
this._register(toDisposable(() => this.clear()));
216
}
217
218
private clear(): void {
219
this.container.innerText = '';
220
this.disposables.clear();
221
}
222
223
render(): void {
224
this.clear();
225
226
if (!this.mcpServer?.starsCount) {
227
return;
228
}
229
230
if (this.small && this.mcpServer.installState !== McpServerInstallState.Uninstalled) {
231
return;
232
}
233
234
const parent = this.small ? this.container : dom.append(this.container, dom.$('span.rating', { tabIndex: 0 }));
235
dom.append(parent, dom.$('span' + ThemeIcon.asCSSSelector(mcpStarredIcon)));
236
237
const ratingCountElement = dom.append(parent, dom.$('span.count', undefined, StarredWidget.getCountLabel(this.mcpServer.starsCount)));
238
if (!this.small) {
239
ratingCountElement.style.paddingLeft = '3px';
240
}
241
}
242
243
static getCountLabel(starsCount: number): string {
244
if (starsCount > 1000000) {
245
return `${Math.floor(starsCount / 100000) / 10}M`;
246
} else if (starsCount > 1000) {
247
return `${Math.floor(starsCount / 1000)}K`;
248
} else {
249
return String(starsCount);
250
}
251
}
252
253
}
254
255
export class LicenseWidget extends McpServerWidget {
256
257
private readonly disposables = this._register(new DisposableStore());
258
259
constructor(
260
readonly container: HTMLElement,
261
) {
262
super();
263
this.container.classList.add('license');
264
this.render();
265
this._register(toDisposable(() => this.clear()));
266
}
267
268
private clear(): void {
269
this.container.innerText = '';
270
this.disposables.clear();
271
}
272
273
render(): void {
274
this.clear();
275
276
if (!this.mcpServer?.license) {
277
return;
278
}
279
280
const parent = dom.append(this.container, dom.$('span.license', { tabIndex: 0 }));
281
dom.append(parent, dom.$('span' + ThemeIcon.asCSSSelector(mcpLicenseIcon)));
282
283
const licenseElement = dom.append(parent, dom.$('span', undefined, this.mcpServer.license));
284
licenseElement.style.paddingLeft = '3px';
285
}
286
}
287
288
export class McpServerHoverWidget extends McpServerWidget {
289
290
private readonly hover = this._register(new MutableDisposable<IDisposable>());
291
292
constructor(
293
private readonly options: ExtensionHoverOptions,
294
private readonly mcpServerStatusAction: McpServerStatusAction,
295
@IHoverService private readonly hoverService: IHoverService,
296
@IConfigurationService private readonly configurationService: IConfigurationService,
297
) {
298
super();
299
}
300
301
render(): void {
302
this.hover.value = undefined;
303
if (this.mcpServer) {
304
this.hover.value = this.hoverService.setupManagedHover({
305
delay: this.configurationService.getValue<number>('workbench.hover.delay'),
306
showHover: (options, focus) => {
307
return this.hoverService.showInstantHover({
308
...options,
309
additionalClasses: ['extension-hover'],
310
position: {
311
hoverPosition: this.options.position(),
312
forcePosition: true,
313
},
314
persistence: {
315
hideOnKeyDown: true,
316
}
317
}, focus);
318
},
319
placement: 'element'
320
},
321
this.options.target,
322
{
323
markdown: () => Promise.resolve(this.getHoverMarkdown()),
324
markdownNotSupportedFallback: undefined
325
},
326
{
327
appearance: {
328
showHoverHint: true
329
}
330
}
331
);
332
}
333
}
334
335
private getHoverMarkdown(): MarkdownString | undefined {
336
if (!this.mcpServer) {
337
return undefined;
338
}
339
const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });
340
341
markdown.appendMarkdown(`**${this.mcpServer.label}**`);
342
markdown.appendText(`\n`);
343
344
let addSeparator = false;
345
if (this.mcpServer.local?.scope === LocalMcpServerScope.Workspace) {
346
markdown.appendMarkdown(`$(${mcpServerWorkspaceIcon.id})&nbsp;`);
347
markdown.appendMarkdown(localize('workspace extension', "Workspace MCP Server"));
348
addSeparator = true;
349
}
350
351
if (this.mcpServer.local?.scope === LocalMcpServerScope.RemoteUser) {
352
markdown.appendMarkdown(`$(${mcpServerRemoteIcon.id})&nbsp;`);
353
markdown.appendMarkdown(localize('remote user extension', "Remote MCP Server"));
354
addSeparator = true;
355
}
356
357
if (this.mcpServer.installState === McpServerInstallState.Installed) {
358
if (this.mcpServer.starsCount) {
359
if (addSeparator) {
360
markdown.appendText(` | `);
361
}
362
const starsCountLabel = StarredWidget.getCountLabel(this.mcpServer.starsCount);
363
markdown.appendMarkdown(`$(${mcpStarredIcon.id}) ${starsCountLabel}`);
364
addSeparator = true;
365
}
366
}
367
368
if (addSeparator) {
369
markdown.appendText(`\n`);
370
}
371
372
if (this.mcpServer.description) {
373
markdown.appendMarkdown(`${this.mcpServer.description}`);
374
}
375
376
const extensionStatus = this.mcpServerStatusAction.status;
377
378
if (extensionStatus.length) {
379
380
markdown.appendMarkdown(`---`);
381
markdown.appendText(`\n`);
382
383
for (const status of extensionStatus) {
384
if (status.icon) {
385
markdown.appendMarkdown(`$(${status.icon.id})&nbsp;`);
386
}
387
markdown.appendMarkdown(status.message.value);
388
markdown.appendText(`\n`);
389
}
390
391
}
392
393
return markdown;
394
}
395
396
}
397
398
export class McpServerScopeBadgeWidget extends McpServerWidget {
399
400
private readonly badge = this._register(new MutableDisposable<ExtensionIconBadge>());
401
private element: HTMLElement;
402
403
constructor(
404
readonly container: HTMLElement,
405
@IInstantiationService private readonly instantiationService: IInstantiationService
406
) {
407
super();
408
this.element = dom.append(this.container, dom.$(''));
409
this.render();
410
this._register(toDisposable(() => this.clear()));
411
}
412
413
private clear(): void {
414
this.badge.value?.element.remove();
415
this.badge.clear();
416
}
417
418
render(): void {
419
this.clear();
420
421
const scope = this.mcpServer?.local?.scope;
422
423
if (!scope || scope === LocalMcpServerScope.User) {
424
return;
425
}
426
427
let icon: ThemeIcon;
428
switch (scope) {
429
case LocalMcpServerScope.Workspace: {
430
icon = mcpServerWorkspaceIcon;
431
break;
432
}
433
case LocalMcpServerScope.RemoteUser: {
434
icon = mcpServerRemoteIcon;
435
break;
436
}
437
}
438
439
this.badge.value = this.instantiationService.createInstance(ExtensionIconBadge, icon, undefined);
440
dom.append(this.element, this.badge.value.element);
441
}
442
}
443
444
export class McpServerStatusWidget extends McpServerWidget {
445
446
private readonly renderDisposables = this._register(new MutableDisposable());
447
448
private readonly _onDidRender = this._register(new Emitter<void>());
449
readonly onDidRender: Event<void> = this._onDidRender.event;
450
451
constructor(
452
private readonly container: HTMLElement,
453
private readonly extensionStatusAction: McpServerStatusAction,
454
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
455
) {
456
super();
457
this.render();
458
this._register(extensionStatusAction.onDidChangeStatus(() => this.render()));
459
}
460
461
render(): void {
462
reset(this.container);
463
this.renderDisposables.value = undefined;
464
const disposables = new DisposableStore();
465
this.renderDisposables.value = disposables;
466
const extensionStatus = this.extensionStatusAction.status;
467
if (extensionStatus.length) {
468
const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });
469
for (let i = 0; i < extensionStatus.length; i++) {
470
const status = extensionStatus[i];
471
if (status.icon) {
472
markdown.appendMarkdown(`$(${status.icon.id})&nbsp;`);
473
}
474
markdown.appendMarkdown(status.message.value);
475
if (i < extensionStatus.length - 1) {
476
markdown.appendText(`\n`);
477
}
478
}
479
const rendered = disposables.add(this.markdownRendererService.render(markdown));
480
dom.append(this.container, rendered.element);
481
}
482
this._onDidRender.fire();
483
}
484
}
485
486
export const mcpStarredIconColor = registerColor('mcpIcon.starForeground', { light: '#DF6100', dark: '#FF8E00', hcDark: '#FF8E00', hcLight: textLinkForeground }, localize('mcpIconStarForeground', "The icon color for mcp starred."), false);
487
488
registerThemingParticipant((theme, collector) => {
489
const mcpStarredIconColorValue = theme.getColor(mcpStarredIconColor);
490
if (mcpStarredIconColorValue) {
491
collector.addRule(`.extension-ratings .codicon-mcp-server-starred { color: ${mcpStarredIconColorValue}; }`);
492
collector.addRule(`.monaco-hover.extension-hover .markdown-hover .hover-contents ${ThemeIcon.asCSSSelector(mcpStarredIcon)} { color: ${mcpStarredIconColorValue}; }`);
493
}
494
});
495
496
497