Skip to content

Commit 8c9771f

Browse files
authored
mcp: support new light/dark theme for mcp icons (microsoft#269437)
For merging after endgame
1 parent aaeb288 commit 8c9771f

7 files changed

Lines changed: 127 additions & 31 deletions

File tree

src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,9 +317,9 @@ export async function showToolsPicker(
317317
children,
318318
buttons,
319319
};
320-
const iconURI = mcpServer.serverMetadata.get()?.icons.getUrl(22);
321-
if (iconURI) {
322-
bucket.iconPath = { dark: iconURI, light: iconURI };
320+
const iconPath = mcpServer.serverMetadata.get()?.icons.getUrl(22);
321+
if (iconPath) {
322+
bucket.iconPath = iconPath;
323323
} else {
324324
bucket.iconClass = ThemeIcon.asClassName(Codicon.mcp);
325325
}

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ export class McpResourcePickHelper {
3737
}
3838

3939
public static item(resource: IMcpResource | IMcpResourceTemplate): IQuickPickItem {
40-
const icon = resource.icons.getUrl(22);
41-
const iconPath = icon ? { dark: icon, light: icon } : undefined;
40+
const iconPath = resource.icons.getUrl(22);
4241
if (isMcpResourceTemplate(resource)) {
4342
return {
4443
id: resource.template.template,

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,17 @@ const mcpAllowableContentTypes: readonly string[] = [
1818
'image/gif'
1919
];
2020

21+
const enum IconTheme {
22+
Light,
23+
Dark,
24+
Any,
25+
}
26+
2127
interface IIcon {
2228
/** URI the image can be loaded from */
2329
src: URI;
30+
/** Theme for this icon. */
31+
theme: IconTheme;
2432
/** Sizes of the icon in ascending order. */
2533
sizes: { width: number; height: number }[];
2634
}
@@ -80,6 +88,7 @@ export function parseAndValidateMcpIcon(icons: MCP.Icons, launch: McpServerLaunc
8088
const sizesArr = typeof icon.sizes === 'string' ? icon.sizes.split(' ') : Array.isArray(icon.sizes) ? icon.sizes : [];
8189
result.push({
8290
src: uri,
91+
theme: icon.theme === 'light' ? IconTheme.Light : icon.theme === 'dark' ? IconTheme.Dark : IconTheme.Any,
8392
sizes: sizesArr.map(size => {
8493
const [widthStr, heightStr] = size.toLowerCase().split('x');
8594
return { width: Number(widthStr) || 0, height: Number(heightStr) || 0 };
@@ -94,7 +103,7 @@ export function parseAndValidateMcpIcon(icons: MCP.Icons, launch: McpServerLaunc
94103

95104
export class McpIcons implements IMcpIcons {
96105
public static fromStored(icons: StoredMcpIcons | undefined) {
97-
return McpIcons.fromParsed(icons?.map(i => ({ src: URI.revive(i.src), sizes: i.sizes })));
106+
return McpIcons.fromParsed(icons?.map(i => ({ src: URI.revive(i.src), theme: i.theme, sizes: i.sizes })));
98107
}
99108

100109
public static fromParsed(icons: ParsedMcpIcons | undefined) {
@@ -103,14 +112,33 @@ export class McpIcons implements IMcpIcons {
103112

104113
protected constructor(private readonly _icons: IIcon[]) { }
105114

106-
getUrl(size: number): URI | undefined {
115+
getUrl(size: number): { dark: URI; light?: URI } | undefined {
116+
const dark = this.getSizeWithTheme(size, IconTheme.Dark);
117+
if (dark?.theme === IconTheme.Any) {
118+
return { dark: dark.src };
119+
}
120+
121+
const light = this.getSizeWithTheme(size, IconTheme.Light);
122+
if (!light && !dark) {
123+
return undefined;
124+
}
125+
126+
return { dark: (dark || light)!.src, light: light?.src };
127+
}
128+
129+
private getSizeWithTheme(size: number, theme: IconTheme): IIcon | undefined {
130+
let bestOfAnySize: IIcon | undefined;
131+
107132
for (const icon of this._icons) {
108-
const firstWidth = icon.sizes[0]?.width ?? 0;
109-
if (firstWidth > size) {
110-
return icon.src;
133+
if (icon.theme === theme || icon.theme === IconTheme.Any || icon.theme === undefined) { // undefined check for back compat
134+
bestOfAnySize = icon;
135+
136+
const matchingSize = icon.sizes.find(s => s.width >= size);
137+
if (matchingSize) {
138+
return { ...icon, sizes: [matchingSize] };
139+
}
111140
}
112141
}
113-
114-
return this._icons.at(-1)?.src;
142+
return bestOfAnySize;
115143
}
116144
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,11 @@ export class McpLanguageModelToolContribution extends Disposable implements IWor
122122
const collection = collectionObservable.read(reader);
123123
for (const tool of server.tools.read(reader)) {
124124
const existing = tools.get(tool.id);
125-
const iconURI = tool.icons.getUrl(22);
125+
const icons = tool.icons.getUrl(22);
126126
const toolData: IToolData = {
127127
id: tool.id,
128128
source: collectionData.value.source,
129-
icon: iconURI ? { dark: iconURI, light: iconURI } : Codicon.tools,
129+
icon: icons || Codicon.tools,
130130
// duplicative: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/813
131131
displayName: tool.definition.annotations?.title || tool.definition.title || tool.definition.name,
132132
toolReferenceName: tool.referenceName,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,5 +885,5 @@ export interface IMcpToolResourceLinkContents {
885885

886886
export interface IMcpIcons {
887887
/** Gets the image URI appropriate to the approximate display size */
888-
getUrl(size: number): URI | undefined;
888+
getUrl(size: number): { dark: URI; light?: URI } | undefined;
889889
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,15 @@ export namespace MCP {/* JSON-RPC types */
347347
* If not provided, the client should assume that the icon can be used at any size.
348348
*/
349349
sizes?: string;
350+
351+
/**
352+
* Optional specifier for the theme this icon is designed for. `light` indicates
353+
* the icon is designed to be used with a light background, and `dark` indicates
354+
* the icon is designed to be used with a dark background.
355+
*
356+
* If not provided, the client should assume the icon can be used with any theme.
357+
*/
358+
theme?: 'light' | 'dark';
350359
}
351360

352361
/**

src/vs/workbench/contrib/mcp/test/common/mcpIcons.test.ts

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,28 @@ import * as assert from 'assert';
77
import { URI } from '../../../../../base/common/uri.js';
88
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
99
import { NullLogger } from '../../../../../platform/log/common/log.js';
10-
import { parseAndValidateMcpIcon } from '../../common/mcpIcons.js';
10+
import { McpIcons, parseAndValidateMcpIcon } from '../../common/mcpIcons.js';
1111
import { McpServerTransportHTTP, McpServerTransportStdio, McpServerTransportType } from '../../common/mcpTypes.js';
1212

13+
const createHttpLaunch = (url: string): McpServerTransportHTTP => ({
14+
type: McpServerTransportType.HTTP,
15+
uri: URI.parse(url),
16+
headers: []
17+
});
18+
19+
const createStdioLaunch = (): McpServerTransportStdio => ({
20+
type: McpServerTransportType.Stdio,
21+
cwd: undefined,
22+
command: 'cmd',
23+
args: [],
24+
env: {},
25+
envFile: undefined
26+
});
27+
1328
suite('MCP Icons', () => {
1429
suite('parseAndValidateMcpIcon', () => {
1530
ensureNoDisposablesAreLeakedInTestSuite();
1631

17-
const createHttpLaunch = (url: string): McpServerTransportHTTP => ({
18-
type: McpServerTransportType.HTTP,
19-
uri: URI.parse(url),
20-
headers: []
21-
});
22-
23-
const createStdioLaunch = (): McpServerTransportStdio => ({
24-
type: McpServerTransportType.Stdio,
25-
cwd: undefined,
26-
command: 'cmd',
27-
args: [],
28-
env: {},
29-
envFile: undefined
30-
});
31-
3232
test('includes supported icons and sorts sizes ascending', () => {
3333
const logger = new NullLogger();
3434
const launch = createHttpLaunch('https://example.com');
@@ -86,4 +86,64 @@ suite('MCP Icons', () => {
8686
assert.strictEqual(httpResult.length, 0);
8787
});
8888
});
89+
90+
suite('McpIcons', () => {
91+
ensureNoDisposablesAreLeakedInTestSuite();
92+
93+
test('getUrl returns undefined when no icons are available', () => {
94+
const icons = McpIcons.fromParsed(undefined);
95+
assert.strictEqual(icons.getUrl(16), undefined);
96+
});
97+
98+
test('getUrl prefers theme-specific icons and keeps light fallback', () => {
99+
const logger = new NullLogger();
100+
const launch = createHttpLaunch('https://example.com');
101+
const parsed = parseAndValidateMcpIcon({
102+
icons: [
103+
{ src: 'https://example.com/dark.png', mimeType: 'image/png', sizes: '16x16 48x48', theme: 'dark' },
104+
{ src: 'https://example.com/any.png', mimeType: 'image/png', sizes: '24x24' },
105+
{ src: 'https://example.com/light.png', mimeType: 'image/png', sizes: '64x64', theme: 'light' }
106+
]
107+
}, launch, logger);
108+
const icons = McpIcons.fromParsed(parsed);
109+
const result = icons.getUrl(32);
110+
111+
assert.ok(result);
112+
assert.strictEqual(result!.dark.toString(), 'https://example.com/dark.png');
113+
assert.strictEqual(result!.light?.toString(), 'https://example.com/light.png');
114+
});
115+
116+
test('getUrl falls back to any-theme icons when no exact size exists', () => {
117+
const logger = new NullLogger();
118+
const launch = createHttpLaunch('https://example.com');
119+
const parsed = parseAndValidateMcpIcon({
120+
icons: [
121+
{ src: 'https://example.com/dark.png', mimeType: 'image/png', sizes: '16x16', theme: 'dark' },
122+
{ src: 'https://example.com/any.png', mimeType: 'image/png', sizes: '64x64' }
123+
]
124+
}, launch, logger);
125+
const icons = McpIcons.fromParsed(parsed);
126+
const result = icons.getUrl(60);
127+
128+
assert.ok(result);
129+
assert.strictEqual(result!.dark.toString(), 'https://example.com/any.png');
130+
assert.strictEqual(result!.light, undefined);
131+
});
132+
133+
test('getUrl reuses light icons when dark theme assets are missing', () => {
134+
const logger = new NullLogger();
135+
const launch = createHttpLaunch('https://example.com');
136+
const parsed = parseAndValidateMcpIcon({
137+
icons: [
138+
{ src: 'https://example.com/light.png', mimeType: 'image/png', sizes: '32x32', theme: 'light' }
139+
]
140+
}, launch, logger);
141+
const icons = McpIcons.fromParsed(parsed);
142+
const result = icons.getUrl(16);
143+
144+
assert.ok(result);
145+
assert.strictEqual(result!.dark.toString(), 'https://example.com/light.png');
146+
assert.strictEqual(result!.light?.toString(), 'https://example.com/light.png');
147+
});
148+
});
89149
});

0 commit comments

Comments
 (0)