Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpIcons.ts
4780 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { getMediaMime } from '../../../../base/common/mime.js';6import { URI } from '../../../../base/common/uri.js';7import { ILogger } from '../../../../platform/log/common/log.js';8import { Dto } from '../../../services/extensions/common/proxyIdentifier.js';9import { IMcpIcons, McpServerLaunch, McpServerTransportType } from './mcpTypes.js';10import { MCP } from './modelContextProtocol.js';1112const mcpAllowableContentTypes: readonly string[] = [13'image/webp',14'image/png',15'image/jpeg',16'image/jpg',17'image/gif'18];1920const enum IconTheme {21Light,22Dark,23Any,24}2526interface IIcon {27/** URI the image can be loaded from */28src: URI;29/** Theme for this icon. */30theme: IconTheme;31/** Sizes of the icon in ascending order. */32sizes: { width: number; height: number }[];33}3435export type ParsedMcpIcons = IIcon[];36export type StoredMcpIcons = Dto<IIcon>[];373839function validateIcon(icon: MCP.Icon, launch: McpServerLaunch, logger: ILogger): URI | undefined {40const mimeType = icon.mimeType?.toLowerCase() || getMediaMime(icon.src);41if (!mimeType || !mcpAllowableContentTypes.includes(mimeType)) {42logger.debug(`Ignoring icon with unsupported mime type: ${icon.src} (${mimeType}), allowed: ${mcpAllowableContentTypes.join(', ')}`);43return;44}4546const uri = URI.parse(icon.src);47if (uri.scheme === 'data') {48return uri;49}5051if (uri.scheme === 'https' || uri.scheme === 'http') {52if (launch.type !== McpServerTransportType.HTTP) {53logger.debug(`Ignoring icon with HTTP/HTTPS URL: ${icon.src} as the MCP server is not launched with HTTP transport.`);54return;55}5657const expectedAuthority = launch.uri.authority.toLowerCase();58if (uri.authority.toLowerCase() !== expectedAuthority) {59logger.debug(`Ignoring icon with untrusted authority: ${icon.src}, expected authority: ${expectedAuthority}`);60return;61}6263return uri;64}6566if (uri.scheme === 'file') {67if (launch.type !== McpServerTransportType.Stdio) {68logger.debug(`Ignoring icon with file URL: ${icon.src} as the MCP server is not launched as a local process.`);69return;70}7172return uri;73}7475logger.debug(`Ignoring icon with unsupported scheme: ${icon.src}. Allowed: data:, http:, https:, file:`);76return;77}7879export function parseAndValidateMcpIcon(icons: MCP.Icons, launch: McpServerLaunch, logger: ILogger): ParsedMcpIcons {80const result: ParsedMcpIcons = [];81for (const icon of icons.icons || []) {82const uri = validateIcon(icon, launch, logger);83if (!uri) {84continue;85}8687// check for sizes as string for back-compat with early 2025-11-25 drafts88const sizesArr = typeof icon.sizes === 'string' ? (icon.sizes as string).split(' ') : Array.isArray(icon.sizes) ? icon.sizes : [];89result.push({90src: uri,91theme: icon.theme === 'light' ? IconTheme.Light : icon.theme === 'dark' ? IconTheme.Dark : IconTheme.Any,92sizes: sizesArr.map(size => {93const [widthStr, heightStr] = size.toLowerCase().split('x');94return { width: Number(widthStr) || 0, height: Number(heightStr) || 0 };95}).sort((a, b) => a.width - b.width)96});97}9899result.sort((a, b) => a.sizes[0]?.width - b.sizes[0]?.width);100101return result;102}103104export class McpIcons implements IMcpIcons {105public static fromStored(icons: StoredMcpIcons | undefined) {106return McpIcons.fromParsed(icons?.map(i => ({ src: URI.revive(i.src), theme: i.theme, sizes: i.sizes })));107}108109public static fromParsed(icons: ParsedMcpIcons | undefined) {110return new McpIcons(icons || []);111}112113protected constructor(private readonly _icons: IIcon[]) { }114115getUrl(size: number): { dark: URI; light?: URI } | undefined {116const dark = this.getSizeWithTheme(size, IconTheme.Dark);117if (dark?.theme === IconTheme.Any) {118return { dark: dark.src };119}120121const light = this.getSizeWithTheme(size, IconTheme.Light);122if (!light && !dark) {123return undefined;124}125126return { dark: (dark || light)!.src, light: light?.src };127}128129private getSizeWithTheme(size: number, theme: IconTheme): IIcon | undefined {130let bestOfAnySize: IIcon | undefined;131132for (const icon of this._icons) {133if (icon.theme === theme || icon.theme === IconTheme.Any || icon.theme === undefined) { // undefined check for back compat134bestOfAnySize = icon;135136const matchingSize = icon.sizes.find(s => s.width >= size);137if (matchingSize) {138return { ...icon, sizes: [matchingSize] };139}140}141}142return bestOfAnySize;143}144}145146147