Skip to content

Commit 74b360a

Browse files
authored
mcp: improve hover on refresh/error icon, allow toggling autorun state (microsoft#257925)
* mcp: improve hover on refresh/error icon, allow toggling autorun state * add collection information too
1 parent a9ea169 commit 74b360a

File tree

12 files changed

+196
-34
lines changed

12 files changed

+196
-34
lines changed

src/vs/base/browser/ui/actionbar/actionViewItems.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import * as platform from '../../../common/platform.js';
1919
import * as types from '../../../common/types.js';
2020
import './actionbar.css';
2121
import * as nls from '../../../../nls.js';
22-
import type { IManagedHover } from '../hover/hover.js';
22+
import type { IManagedHover, IManagedHoverContent } from '../hover/hover.js';
2323
import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';
2424

2525
export interface IBaseActionViewItemOptions {
@@ -221,11 +221,15 @@ export class BaseActionViewItem extends Disposable implements IActionViewItem {
221221
return this.action.tooltip;
222222
}
223223

224+
protected getHoverContents(): IManagedHoverContent | undefined {
225+
return this.getTooltip();
226+
}
227+
224228
protected updateTooltip(): void {
225229
if (!this.element) {
226230
return;
227231
}
228-
const title = this.getTooltip() ?? '';
232+
const title = this.getHoverContents() ?? '';
229233
this.updateAriaLabel();
230234

231235
if (!this.customHover && title !== '') {

src/vs/base/common/arraysFind.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import { Comparator } from './arrays.js';
77

8+
export function findLast<T, R extends T>(array: readonly T[], predicate: (item: T) => item is R, fromIndex?: number): R | undefined;
9+
export function findLast<T>(array: readonly T[], predicate: (item: T) => unknown, fromIndex?: number): T | undefined;
810
export function findLast<T>(array: readonly T[], predicate: (item: T) => unknown, fromIndex = array.length - 1): T | undefined {
911
const idx = findLastIdx(array, predicate, fromIndex);
1012
if (idx === -1) {

src/vs/editor/browser/services/hoverService/updatableHoverWidget.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ export class ManagedHoverWidget implements IDisposable {
2222

2323
constructor(private hoverDelegate: IHoverDelegate, private target: IHoverDelegateTarget | HTMLElement, private fadeInAnimation: boolean) { }
2424

25+
onDidHide() {
26+
if (this._cancellationTokenSource) {
27+
// there's an computation ongoing, cancel it
28+
this._cancellationTokenSource.dispose(true);
29+
this._cancellationTokenSource = undefined;
30+
}
31+
}
32+
2533
async update(content: IManagedHoverContent, focus?: boolean, options?: IManagedHoverOptions): Promise<void> {
2634
if (this._cancellationTokenSource) {
2735
// there's an computation ongoing, cancel it

src/vs/workbench/contrib/mcp/browser/mcpCommands.ts

Lines changed: 132 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { h } from '../../../../base/browser/dom.js';
6+
import { $, addDisposableListener, disposableWindowInterval, EventType, h } from '../../../../base/browser/dom.js';
7+
import { renderMarkdown } from '../../../../base/browser/markdownRenderer.js';
8+
import { IManagedHoverTooltipHTMLElement } from '../../../../base/browser/ui/hover/hover.js';
9+
import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js';
10+
import { mainWindow } from '../../../../base/browser/window.js';
11+
import { findLast } from '../../../../base/common/arraysFind.js';
712
import { assertNever } from '../../../../base/common/assert.js';
813
import { VSBuffer } from '../../../../base/common/buffer.js';
914
import { Codicon } from '../../../../base/common/codicons.js';
1015
import { groupBy } from '../../../../base/common/collections.js';
1116
import { Event } from '../../../../base/common/event.js';
12-
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
13-
import { autorun, derived } from '../../../../base/common/observable.js';
17+
import { markdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js';
18+
import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
19+
import { autorun, derived, derivedObservableWithCache, observableValue } from '../../../../base/common/observable.js';
1420
import { ThemeIcon } from '../../../../base/common/themables.js';
1521
import { isDefined } from '../../../../base/common/types.js';
1622
import { URI } from '../../../../base/common/uri.js';
@@ -31,6 +37,7 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js';
3137
import { IProductService } from '../../../../platform/product/common/productService.js';
3238
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
3339
import { StorageScope } from '../../../../platform/storage/common/storage.js';
40+
import { defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js';
3441
import { spinningLoading } from '../../../../platform/theme/common/iconRegistry.js';
3542
import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js';
3643
import { PICK_WORKSPACE_FOLDER_COMMAND_ID } from '../../../browser/actions/workspaceCommands.js';
@@ -54,9 +61,10 @@ import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js';
5461
import { McpCommandIds } from '../common/mcpCommandIds.js';
5562
import { McpContextKeys } from '../common/mcpContextKeys.js';
5663
import { IMcpRegistry } from '../common/mcpRegistryTypes.js';
57-
import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpConnectionState, McpDefinitionReference, mcpPromptPrefix, McpServerCacheState, McpStartServerInteraction } from '../common/mcpTypes.js';
64+
import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, mcpPromptPrefix, McpServerCacheState, McpStartServerInteraction } from '../common/mcpTypes.js';
5865
import { McpAddConfigurationCommand } from './mcpCommandsAddConfiguration.js';
5966
import { McpResourceQuickAccess, McpResourceQuickPick } from './mcpResourceQuickAccess.js';
67+
import './media/mcpServerAction.css';
6068
import { openPanelChatAndGetWidget } from './openPanelChatAndGetWidget.js';
6169

6270
// acroynms do not get localized
@@ -358,6 +366,7 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
358366
) {
359367
super();
360368

369+
const hoverIsOpen = observableValue(this, false);
361370
const config = observableConfigValue(mcpAutoStartConfig, McpAutoStartValue.NewAndOutdated, configurationService);
362371

363372
const enum DisplayedState {
@@ -367,9 +376,18 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
367376
Refreshing,
368377
}
369378

370-
const displayedState = derived((reader) => {
379+
type DisplayedStateT = {
380+
state: DisplayedState;
381+
servers: (IMcpServer | McpCollectionDefinition)[];
382+
};
383+
384+
function isServer(s: IMcpServer | McpCollectionDefinition): s is IMcpServer {
385+
return typeof (s as IMcpServer).start === 'function';
386+
}
387+
388+
const displayedStateCurrent = derived((reader): DisplayedStateT => {
371389
const servers = mcpService.servers.read(reader);
372-
const serversPerState: IMcpServer[][] = [];
390+
const serversPerState: (IMcpServer | McpCollectionDefinition)[][] = [];
373391
for (const server of servers) {
374392
let thisState = DisplayedState.None;
375393
switch (server.cacheState.read(reader)) {
@@ -390,10 +408,12 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
390408
}
391409

392410
const unknownServerStates = mcpService.lazyCollectionState.read(reader);
393-
if (unknownServerStates === LazyCollectionState.LoadingUnknown) {
411+
if (unknownServerStates.state === LazyCollectionState.LoadingUnknown) {
394412
serversPerState[DisplayedState.Refreshing] ??= [];
395-
} else if (unknownServerStates === LazyCollectionState.HasUnknown) {
413+
serversPerState[DisplayedState.Refreshing].push(...unknownServerStates.collections);
414+
} else if (unknownServerStates.state === LazyCollectionState.HasUnknown) {
396415
serversPerState[DisplayedState.NewTools] ??= [];
416+
serversPerState[DisplayedState.NewTools].push(...unknownServerStates.collections);
397417
}
398418

399419
let maxState = (serversPerState.length - 1) as DisplayedState;
@@ -404,6 +424,15 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
404424
return { state: maxState, servers: serversPerState[maxState] || [] };
405425
});
406426

427+
// avoid hiding the hover if a state changes while it's open:
428+
const displayedState = derivedObservableWithCache<DisplayedStateT>(this, (reader, last) => {
429+
if (last && hoverIsOpen.read(reader)) {
430+
return last;
431+
} else {
432+
return displayedStateCurrent.read(reader);
433+
}
434+
});
435+
407436
this._store.add(actionViewItemService.register(MenuId.ChatExecute, McpCommandIds.ListServer, (action, options) => {
408437
if (!(action instanceof MenuItemAction)) {
409438
return undefined;
@@ -419,7 +448,8 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
419448
const action = h('button.chat-mcp-action', [h('span@icon')]);
420449

421450
this._register(autorun(r => {
422-
const { state } = displayedState.read(r);
451+
const displayed = displayedState.read(r);
452+
const { state } = displayed;
423453
const { root, icon } = action;
424454
this.updateTooltip();
425455
container.classList.toggle('chat-mcp-has-action', state !== DisplayedState.None);
@@ -428,7 +458,7 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
428458
container.appendChild(root);
429459
}
430460

431-
root.ariaLabel = this.getLabelForState(displayedState.read(r));
461+
root.ariaLabel = this.getLabelForState(displayed);
432462
root.className = 'chat-mcp-action';
433463
icon.className = '';
434464
if (state === DisplayedState.NewTools) {
@@ -450,16 +480,17 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
450480
e.preventDefault();
451481
e.stopPropagation();
452482

453-
const { state, servers } = displayedState.get();
483+
const { state, servers } = displayedStateCurrent.get();
454484
if (state === DisplayedState.NewTools) {
455485
const interaction = new McpStartServerInteraction();
456-
servers.forEach(server => server.stop().then(() => server.start({ interaction })));
486+
servers.filter(isServer).forEach(server => server.stop().then(() => server.start({ interaction })));
457487
mcpService.activateCollections();
458488
} else if (state === DisplayedState.Refreshing) {
459-
servers.at(-1)?.showOutput();
489+
findLast(servers, isServer)?.showOutput();
460490
} else if (state === DisplayedState.Error) {
461-
const server = servers.at(-1);
491+
const server = findLast(servers, isServer);
462492
if (server) {
493+
server.showOutput();
463494
commandService.executeCommand(McpCommandIds.ServerOptions, server.definition.id);
464495
}
465496
} else {
@@ -471,7 +502,93 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
471502
return this.getLabelForState() || super.getTooltip();
472503
}
473504

474-
private getLabelForState({ state, servers } = displayedState.get()) {
505+
protected override getHoverContents({ state, servers } = displayedStateCurrent.get()): string | undefined | IManagedHoverTooltipHTMLElement {
506+
const link = (s: IMcpServer) => markdownCommandLink({
507+
title: s.definition.label,
508+
id: McpCommandIds.ServerOptions,
509+
arguments: [s.definition.id],
510+
});
511+
512+
const single = servers.length === 1;
513+
const names = servers.map(s => isServer(s) ? link(s) : '`' + s.label + '`').map(l => single ? l : `- ${l}\n`).join(', ');
514+
let markdown: MarkdownString;
515+
if (state === DisplayedState.NewTools) {
516+
markdown = new MarkdownString(single
517+
? localize('mcp.newTools.md.single', "MCP server {0} has been updated and may have new tools available.", names)
518+
: localize('mcp.newTools.md.multi', "MCP servers have been updated and may have new tools available:\n\n{0}", names)
519+
);
520+
} else if (state === DisplayedState.Error) {
521+
markdown = new MarkdownString(single
522+
? localize('mcp.err.md.single', "MCP server {0} was unable to start successfully.", names)
523+
: localize('mcp.err.md.multi', "Multiple MCP servers were unable to start successfully:\n\n{0}", names)
524+
);
525+
} else {
526+
return this.getLabelForState() || undefined;
527+
}
528+
529+
return {
530+
element: (token): HTMLElement => {
531+
hoverIsOpen.set(true, undefined);
532+
533+
const store = new DisposableStore();
534+
store.add(toDisposable(() => hoverIsOpen.set(false, undefined)));
535+
store.add(token.onCancellationRequested(() => {
536+
store.dispose();
537+
}));
538+
539+
// todo@connor4312/@benibenj: workaround for #257923
540+
store.add(disposableWindowInterval(mainWindow, () => {
541+
if (!container.isConnected) {
542+
store.dispose();
543+
}
544+
}, 2000));
545+
546+
const container = $('div.mcp-hover-contents');
547+
548+
// Render markdown content
549+
markdown.isTrusted = true;
550+
const markdownResult = store.add(renderMarkdown(markdown));
551+
container.appendChild(markdownResult.element);
552+
553+
// Add divider
554+
const divider = $('hr.mcp-hover-divider');
555+
container.appendChild(divider);
556+
557+
// Add checkbox for mcpAutoStartConfig setting
558+
const checkboxContainer = $('div.mcp-hover-setting');
559+
const settingLabelStr = localize('mcp.autoStart', "Automatically start MCP servers when sending a chat message");
560+
561+
const checkbox = store.add(new Checkbox(
562+
settingLabelStr,
563+
config.get() !== McpAutoStartValue.Never,
564+
defaultCheckboxStyles
565+
));
566+
567+
checkboxContainer.appendChild(checkbox.domNode);
568+
569+
// Add label next to checkbox
570+
const settingLabel = $('span.mcp-hover-setting-label', undefined, settingLabelStr);
571+
checkboxContainer.appendChild(settingLabel);
572+
573+
const onChange = () => {
574+
const newValue = checkbox.checked ? McpAutoStartValue.NewAndOutdated : McpAutoStartValue.Never;
575+
configurationService.updateValue(mcpAutoStartConfig, newValue);
576+
};
577+
578+
store.add(checkbox.onChange(onChange));
579+
580+
store.add(addDisposableListener(settingLabel, EventType.CLICK, () => {
581+
checkbox.checked = !checkbox.checked;
582+
onChange();
583+
}));
584+
container.appendChild(checkboxContainer);
585+
586+
return container;
587+
},
588+
};
589+
}
590+
591+
private getLabelForState({ state, servers } = displayedStateCurrent.get()) {
475592
if (state === DisplayedState.NewTools) {
476593
return localize('mcp.newTools', "New tools available ({0})", servers.length || 1);
477594
} else if (state === DisplayedState.Error) {
@@ -482,8 +599,6 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo
482599
return null;
483600
}
484601
}
485-
486-
487602
}, action, { ...options, keybindingNotRenderedWithLabel: true });
488603

489604
}, Event.fromObservable(displayedState)));
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
.mcp-hover-contents {
7+
margin-top: 4px;
8+
margin-bottom: 4px;
9+
max-width: 250px;
10+
min-width: 200px;
11+
}
12+
13+
.mcp-hover-contents .mcp-hover-divider {
14+
margin-top: 8px;
15+
margin-bottom: 8px;
16+
}
17+
18+
.mcp-hover-contents .mcp-hover-setting {
19+
display: flex;
20+
align-items: center;
21+
margin-top: 6px;
22+
}
23+
24+
.mcp-hover-contents .mcp-hover-setting .monaco-checkbox {
25+
flex-shrink: 0;
26+
}
27+
28+
.mcp-hover-contents .mcp-hover-setting .mcp-hover-setting-label {
29+
cursor: pointer;
30+
color: var(--vscode-foreground);
31+
font-size: 12px;
32+
}

src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class McpContextKeysController extends Disposable implements IWorkbenchCo
5050
const serverTools = servers.map(s => s.tools.read(r));
5151
ctxServerCount.set(servers.length);
5252
ctxToolsCount.set(serverTools.reduce((count, tools) => count + tools.length, 0));
53-
ctxHasUnknownTools.set(mcpService.lazyCollectionState.read(r) !== LazyCollectionState.AllKnown || servers.some(s => {
53+
ctxHasUnknownTools.set(mcpService.lazyCollectionState.read(r).state !== LazyCollectionState.AllKnown || servers.some(s => {
5454
const toolState = s.cacheState.read(r);
5555
return toolState === McpServerCacheState.Unknown || toolState === McpServerCacheState.Outdated || toolState === McpServerCacheState.RefreshingFromUnknown;
5656
}));

src/vs/workbench/contrib/mcp/common/mcpRegistry.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,15 @@ export class McpRegistry extends Disposable implements IMcpRegistry {
5757

5858
public readonly lazyCollectionState = derived(reader => {
5959
if (this._enabled.read(reader) === false) {
60-
return LazyCollectionState.AllKnown;
60+
return { state: LazyCollectionState.AllKnown, collections: [] };
6161
}
6262

6363
if (this._ongoingLazyActivations.read(reader) > 0) {
64-
return LazyCollectionState.LoadingUnknown;
64+
return { state: LazyCollectionState.LoadingUnknown, collections: [] };
6565
}
6666
const collections = this._collections.read(reader);
67-
return collections.some(c => c.lazy && c.lazy.isCached === false) ? LazyCollectionState.HasUnknown : LazyCollectionState.AllKnown;
67+
const hasUnknown = collections.some(c => c.lazy && c.lazy.isCached === false);
68+
return hasUnknown ? { state: LazyCollectionState.HasUnknown, collections: collections.filter(c => c.lazy && c.lazy.isCached === false) } : { state: LazyCollectionState.AllKnown, collections: [] };
6869
});
6970

7071
public get delegates(): IObservable<readonly IMcpHostDelegate[]> {

src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export interface IMcpRegistry {
6868
readonly collections: IObservable<readonly McpCollectionDefinition[]>;
6969
readonly delegates: IObservable<readonly IMcpHostDelegate[]>;
7070
/** Whether there are new collections that can be resolved with a discover() call */
71-
readonly lazyCollectionState: IObservable<LazyCollectionState>;
71+
readonly lazyCollectionState: IObservable<{ state: LazyCollectionState; collections: McpCollectionDefinition[] }>;
7272

7373
/** Helper function to observe a definition by its reference. */
7474
getServerDefinition(collectionRef: McpDefinitionReference, definitionRef: McpDefinitionReference): IObservable<{ server: McpServerDefinition | undefined; collection: McpCollectionDefinition | undefined }>;

src/vs/workbench/contrib/mcp/common/mcpTypes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export interface IMcpService {
208208
resetTrust(): void;
209209

210210
/** Set if there are extensions that register MCP servers that have never been activated. */
211-
readonly lazyCollectionState: IObservable<LazyCollectionState>;
211+
readonly lazyCollectionState: IObservable<{ state: LazyCollectionState; collections: McpCollectionDefinition[] }>;
212212
/** Activatese extensions and runs their MCP servers. */
213213
activateCollections(): Promise<void>;
214214
}

0 commit comments

Comments
 (0)