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