webui: Remove Google Favicons & Improve MCP Information logic & UI (#22719)

* refactor: Remove Google favicon utility

* fix: MCP Server favicon

* refactor: Cleanup

* refactor: MCP Server Information

* fix: Fix MCP Settings UI

* refactor: Cleanup
This commit is contained in:
Aleksander Grygier
2026-05-06 11:12:27 +02:00
committed by GitHub
parent f08f20a0e3
commit e3e3f8e46a
24 changed files with 1545 additions and 1268 deletions
File diff suppressed because one or more lines are too long
+1082 -1079
View File
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
import { Settings, Plus } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
import { McpLogo, DropdownMenuSearchable, McpServerIdentity } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
@@ -77,6 +77,8 @@
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
{@const displayName = getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
<button
type="button"
@@ -85,18 +87,16 @@
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if mcpStore.getServerFavicon(server.id)}
<img
src={mcpStore.getServerFavicon(server.id)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
<div class="min-w-0 flex-1">
<McpServerIdentity
{displayName}
{faviconUrl}
iconClass="h-4 w-4"
iconRounded="rounded-sm"
showVersion={false}
nameClass="text-sm"
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
</div>
{#if hasError}
<span
@@ -12,6 +12,7 @@
sortTreeChildren
} from './mcp-resources-browser';
import { getDisplayName, getResourceIcon } from '$lib/utils';
import { McpServerIdentity } from '$lib/components/app/mcp';
interface Props {
serverName: string;
@@ -43,11 +44,12 @@
searchQuery = ''
}: Props = $props();
let serverDisplayName = $derived(mcpStore.getServerDisplayName(serverName));
let serverFaviconUrl = $derived(mcpStore.getServerFavicon(serverName));
const hasResources = $derived(serverRes.resources.length > 0);
const hasTemplates = $derived(serverRes.templates.length > 0);
const hasContent = $derived(hasResources || hasTemplates);
const displayName = $derived(mcpStore.getServerDisplayName(serverName));
const favicon = $derived(mcpStore.getServerFavicon(serverName));
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
@@ -153,21 +155,15 @@
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span class="inline-flex flex-col items-start text-left">
<span class="inline-flex items-center justify-start gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
{displayName}
</span>
<span class="inline-flex flex-col items-start gap-1 text-left">
<div class="inline-flex min-w-0 items-center gap-1.5">
<McpServerIdentity
displayName={serverDisplayName}
faviconUrl={serverFaviconUrl}
iconClass="h-4 w-4"
showVersion={false}
/>
</div>
<span class="text-xs text-muted-foreground">
({serverRes.resources.length} resource{serverRes.resources.length !== 1
@@ -17,17 +17,17 @@
interface Props {
server: MCPServerSettingsEntry;
faviconUrl: string | null;
enabled?: boolean;
onToggle: (enabled: boolean) => void;
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
onDelete: () => void;
}
let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
let { server, enabled, onToggle, onUpdate, onDelete }: Props = $props();
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
let displayName = $derived(mcpStore.getServerLabel(server));
let faviconUrl = $derived(mcpStore.getServerFavicon(server.id));
let isIdle = $derived(healthState.status === HealthCheckStatus.IDLE);
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
@@ -1,15 +1,14 @@
<script lang="ts">
import { Cable, ExternalLink } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import { Badge } from '$lib/components/ui/badge';
import { McpCapabilitiesBadges } from '$lib/components/app/mcp';
import { McpCapabilitiesBadges, McpServerIdentity } from '$lib/components/app/mcp';
import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
import { MCPTransportType } from '$lib/enums';
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
interface Props {
displayName: string;
faviconUrl: string | null;
faviconUrl?: string | null;
enabled: boolean;
disabled?: boolean;
onToggle: (enabled: boolean) => void;
@@ -32,42 +31,16 @@
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="grid min-w-0 gap-3">
<div class="flex items-center gap-2 overflow-hidden">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-5 w-5 shrink-0 rounded"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<div class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted">
<Cable class="h-3 w-3 text-muted-foreground" />
</div>
{/if}
<p class="min-w-0 shrink-0 truncate leading-none font-medium">{displayName}</p>
{#if serverInfo?.version}
<Badge variant="secondary" class="h-4 min-w-0 truncate px-1 text-[10px]">
v{serverInfo.version}
</Badge>
{/if}
{#if serverInfo?.websiteUrl}
<a
href={serverInfo.websiteUrl}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Open website"
>
<ExternalLink class="h-3 w-3" />
</a>
{/if}
<div class="flex min-w-0 flex-col gap-3">
<div class="inline-flex items-center gap-2">
<McpServerIdentity
{displayName}
{faviconUrl}
{serverInfo}
iconClass="h-5 w-5"
iconRounded="rounded"
nameClass="leading-6 font-medium"
/>
</div>
{#if capabilities || transportType}
@@ -0,0 +1,67 @@
<script lang="ts">
import { ExternalLink } from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
import { TruncatedText } from '$lib/components/app/misc';
import { sanitizeExternalUrl } from '$lib/utils';
import type { MCPServerInfo } from '$lib/types';
interface Props {
displayName?: string;
faviconUrl?: string | null;
serverInfo?: MCPServerInfo;
iconClass?: string;
iconRounded?: string;
showVersion?: boolean;
showWebsite?: boolean;
nameClass?: string;
}
let {
displayName,
faviconUrl = null,
serverInfo,
iconClass = 'h-5 w-5',
iconRounded = 'rounded-sm',
showVersion = true,
showWebsite = true,
nameClass
}: Props = $props();
let safeWebsiteUrl = $derived(
serverInfo?.websiteUrl ? sanitizeExternalUrl(serverInfo.websiteUrl) : null
);
</script>
<span class="flex min-w-0 items-center gap-1.5">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class={['shrink-0', iconRounded, iconClass]}
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<TruncatedText text={displayName ?? ''} class={nameClass ?? ''} />
{#if showVersion && serverInfo?.version}
<Badge variant="secondary" class="h-4 min-w-0 shrink px-1 text-[10px]">
<TruncatedText text={`v${serverInfo.version}`} />
</Badge>
{/if}
{#if showWebsite && safeWebsiteUrl}
<a
href={safeWebsiteUrl}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Open website"
onclick={(e) => e.stopPropagation()}
>
<ExternalLink class="h-3 w-3" />
</a>
{/if}
</span>
@@ -180,6 +180,25 @@ export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerC
/** Skeleton loading state for server card during health checks. */
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
/**
* **McpServerIdentity** - Server identity display (icon, name, version)
*
* Reusable headless component for displaying server name, favicon/icon, and version badge.
* Accepts all data via props with no store dependencies for predictable rendering.
*
* **Features:**
* - Server favicon/icon with fallback
* - Truncated display name with max-width
* - Optional version badge (v1.2.3)
* - Optional external link to server website
*
* @example
* ```svelte
* <McpServerIdentity displayName={name} faviconUrl={iconUrl} serverInfo={info} />
* ```
*/
export { default as McpServerIdentity } from './McpServerIdentity.svelte';
/**
* **McpServerInfo** - Server instructions display
*
@@ -32,7 +32,7 @@
{#if isTruncated && showTooltip}
<Tooltip.Root>
<Tooltip.Trigger class={className}>
<Tooltip.Trigger class="{className} min-w-0">
<span bind:this={textElement} class="block truncate">
{text}
</span>
@@ -43,7 +43,7 @@
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span bind:this={textElement} class="{className} block truncate">
<span bind:this={textElement} class="{className} block min-w-0 truncate">
{text}
</span>
{/if}
@@ -170,7 +170,7 @@
>
<Package class="h-3.5 w-3.5" />
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
<TruncatedText text={selectedOption?.model || ''} class="font-medium" />
{#if ms.updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
@@ -2,28 +2,15 @@
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import { TruncatedText } from '$lib/components/app';
import { TruncatedText, McpServerIdentity } from '$lib/components/app';
import { toolsStore } from '$lib/stores/tools.svelte';
import { permissionsStore } from '$lib/stores/permissions.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { ToolSource } from '$lib/enums';
import { SvelteSet } from 'svelte/reactivity';
let expandedGroups = new SvelteSet<string>();
let groups = $derived(toolsStore.toolGroups);
function getFavicon(group: { source: ToolSource; label: string }): string | null {
if (group.source !== ToolSource.MCP) return null;
for (const server of mcpStore.getServersSorted()) {
if (mcpStore.getServerLabel(server) === group.label) {
return mcpStore.getServerFavicon(server.id);
}
}
return null;
}
function toggleExpanded(label: string) {
if (expandedGroups.has(label)) {
expandedGroups.delete(label);
@@ -39,8 +26,6 @@
<div class="space-y-2">
{#each groups as group (group.label)}
{@const isExpanded = expandedGroups.has(group.label)}
{@const favicon = getFavicon(group)}
<Collapsible.Root open={isExpanded} onOpenChange={() => toggleExpanded(group.label)}>
<Collapsible.Trigger
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm hover:bg-muted/50"
@@ -51,19 +36,16 @@
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
{@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
<span class="truncate">{group.label}</span>
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
<McpServerIdentity
iconClass="h-4 w-4"
iconRounded="rounded-sm"
showVersion={false}
displayName={group.label}
{faviconUrl}
/>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
@@ -89,7 +71,7 @@
: false}
<div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
<TruncatedText text={toolName} class="min-w-0 flex-1 truncate" showTooltip={true} />
<TruncatedText text={toolName} class="flex-1" showTooltip={true} />
<div class="flex w-16 shrink-0 justify-center">
<Checkbox
@@ -54,14 +54,14 @@
});
</script>
<div in:fade={{ duration: 150 }} class="max-h-full overflow-auto">
<div in:fade={{ duration: 150 }} class="h-full max-h-[100dvh] overflow-y-auto">
<div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:px-0 md:py-2">
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-xl font-semibold md:text-2xl">MCP Servers</h1>
</div>
<div class="sticky top-0 z-10 mt-4 flex items-start justify-end gap-4 px-8 py-4">
<div class="sticky top-0 z-10 mt-4 flex items-start gap-4 p-4 md:justify-end md:px-8">
<Button variant="outline" size="sm" class="shrink-0" onclick={() => (isAddingServer = true)}>
<Plus class="h-4 w-4" />
@@ -89,7 +89,6 @@
{:else}
<McpServerCard
{server}
faviconUrl={mcpStore.getServerFavicon(server.id)}
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
onToggle={async () => {
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
@@ -1,4 +0,0 @@
export const GOOGLE_FAVICON_BASE_URL = 'https://www.google.com/s2/favicons';
export const DEFAULT_FAVICON_SIZE = 32;
export const DOMAIN_SEPARATOR = '.';
export const ROOT_DOMAIN_MIN_PARTS = 2;
@@ -13,7 +13,6 @@ export * from './code-blocks';
export * from './code';
export * from './context-keys';
export * from './css-classes';
export * from './favicon';
export * from './floating-ui-constraints';
export * from './formatters';
export * from './key-value-pairs';
@@ -40,4 +39,5 @@ export * from './tools';
export * from './tooltip-config';
export * from './ui';
export * from './uri-template';
export * from './url';
export * from './viewport';
+3 -1
View File
@@ -13,7 +13,9 @@ export const MCP_ALLOWED_ICON_MIME_TYPES = new Set([
MimeTypeImage.JPEG,
MimeTypeImage.JPG,
MimeTypeImage.SVG,
MimeTypeImage.WEBP
MimeTypeImage.WEBP,
MimeTypeImage.ICO,
MimeTypeImage.ICO_MICROSOFT
]);
/**
+186
View File
@@ -0,0 +1,186 @@
const STD = ['com', 'net', 'org', 'gov', 'edu'] as const;
const STD_MIL = [...STD, 'mil'] as const;
const ccTLD_PREFIXES: Record<string, readonly string[]> = {
// --- Standard 5 only ---
ar: STD,
bd: STD,
bg: STD,
cn: STD_MIL,
eg: STD,
gr: STD,
hk: STD,
hr: STD,
lk: STD,
mx: STD_MIL,
my: STD_MIL,
ng: STD,
ph: STD,
pk: STD,
pl: STD,
ro: STD,
ru: STD,
sa: STD,
si: STD,
tr: STD,
tw: STD,
ua: STD,
ve: STD,
au: [...STD_MIL, 'id', 'asn', 'csiro'],
br: [
...STD_MIL,
'art',
'eco',
'eng',
'inf',
'med',
'psi',
'tmp',
'etc',
'adm',
'adv',
'arq',
'bio',
'bmd',
'cim',
'cng',
'cnt',
'coop',
'ecn',
'esp',
'far',
'fm',
'fnd',
'fot',
'fst',
'g12',
'ggf',
'imb',
'ind',
'jor',
'jus',
'leg',
'lel',
'mat',
'mp',
'mus',
'not',
'ntr',
'odo',
'ppg',
'pro',
'psc',
'qsl',
'rec',
'slg',
'srv',
'trd',
'tur',
'tv',
'vet',
'vlog',
'wiki',
'zlg'
],
id: [...STD_MIL, 'co', 'go', 'or', 'web', 'sch'],
in: [...STD_MIL, 'co', 'gen', 'ind', 'firm', 'ernet', 'nic'],
kr: [...STD_MIL, 'co', 'go', 'or', 'ac', 're'],
nz: [
...STD_MIL,
'co',
'gen',
'geek',
'kiwi',
'maori',
'school',
'govt',
'health',
'iwi',
'parliament'
],
sg: [...STD, 'per'],
th: ['co', 'go', 'or', 'in', 'ac', 'mi', 'net'],
ae: ['co', 'net', 'org', 'gov', 'ac', 'sch'],
hu: ['co', 'net', 'org', 'gov', 'edu'],
il: ['co', 'net', 'org', 'gov', 'ac', 'muni'],
jp: ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
ke: ['co', 'or', 'ne', 'go', 'ac', 'sc'],
rs: ['co', 'net', 'org', 'gov', 'edu'],
uk: ['co', 'org', 'net', 'ac', 'gov', 'mil', 'nhs', 'police', 'mod', 'ltd', 'plc', 'me', 'sch'],
za: ['co', 'org', 'net', 'web', 'law', 'mil']
};
const WILDCARD_BASES: Record<string, readonly string[]> = {
br: ['nom', 'blog'],
jp: [
'kobe',
'kyoto',
'nagoya',
'osaka',
'sapporo',
'sendai',
'tokyo',
'yokohama',
'aichi',
'akita',
'aomori',
'chiba',
'ehime',
'fukui',
'fukuoka',
'fukushima',
'gifu',
'gunma',
'hiroshima',
'hokkaido',
'hyogo',
'ibaraki',
'ishikawa',
'iwate',
'kagawa',
'kagoshima',
'kanagawa',
'kochi',
'kumamoto',
'mie',
'miyagi',
'miyazaki',
'nagano',
'nara',
'niigata',
'oita',
'okayama',
'okinawa',
'saga',
'saitama',
'shiga',
'shimane',
'shizuoka',
'tochigi',
'tokushima',
'tottori',
'toyama',
'wakayama',
'yamagata',
'yamaguchi',
'yamanashi'
]
};
function buildSuffixSet(suffixes: Record<string, readonly string[]>): Set<string> {
const set = new Set<string>();
for (const [tld, parts] of Object.entries(suffixes)) {
for (const part of parts) {
set.add(`${part}.${tld}`);
}
}
return set;
}
export const TWO_PART_PUBLIC_SUFFIXES = buildSuffixSet(ccTLD_PREFIXES);
export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES);
+3 -1
View File
@@ -182,7 +182,9 @@ export enum MimeTypeImage {
PNG = 'image/png',
GIF = 'image/gif',
WEBP = 'image/webp',
SVG = 'image/svg+xml'
SVG = 'image/svg+xml',
ICO = 'image/x-icon',
ICO_MICROSOFT = 'image/vnd.microsoft.icon'
}
export enum MimeTypeText {
+4 -4
View File
@@ -24,10 +24,10 @@ export enum McpPromptVariant {
*/
export enum UrlProtocol {
DATA = 'data:',
HTTP = 'http://',
HTTPS = 'https://',
WEBSOCKET = 'ws://',
WEBSOCKET_SECURE = 'wss://'
HTTP = 'http:',
HTTPS = 'https:',
WEBSOCKET = 'ws:',
WEBSOCKET_SECURE = 'wss:'
}
export enum HtmlInputType {
@@ -27,6 +27,7 @@ import {
} from '$lib/enums';
import type {
MCPServerConfig,
MCPResourceIcon,
ToolCallParams,
ToolExecutionResult,
Implementation,
@@ -469,10 +470,11 @@ export class MCPService {
title: impl.title,
description: impl.description,
websiteUrl: impl.websiteUrl,
icons: impl.icons?.map((icon: { src: string; mimeType?: string; sizes?: string }) => ({
icons: impl.icons?.map((icon: MCPResourceIcon) => ({
src: icon.src,
mimeType: icon.mimeType,
sizes: icon.sizes
sizes: icon.sizes,
theme: icon.theme
}))
};
}
@@ -581,7 +583,6 @@ export class MCPService {
this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
);
console.log(`[MCPService][${serverName}] Connecting to server...`);
try {
await client.connect(transport);
// Transport diagnostics are only for the initial handshake, not long-lived traffic.
+44 -22
View File
@@ -26,11 +26,10 @@ import { config, settingsStore } from '$lib/stores/settings.svelte';
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { mode } from 'mode-watcher';
import {
getProxiedUrlString,
parseMcpServerSettings,
detectMcpTransportFromUrl,
getFaviconUrl,
uuid
uuid,
extractRootDomain
} from '$lib/utils';
import {
MCPConnectionPhase,
@@ -413,7 +412,9 @@ class MCPStore {
#isValidIconUri(src: string): boolean {
try {
if (src.startsWith(UrlProtocol.DATA)) return true;
const url = new URL(src);
return url.protocol === UrlProtocol.HTTPS;
} catch {
return false;
@@ -446,40 +447,29 @@ class MCPStore {
// 1. Prefer icon explicitly matching the current color scheme
const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme);
if (themedIcon) return this.#proxyIconSrc(themedIcon.src);
if (themedIcon) return themedIcon.src;
// 2. Handle universal icons (no theme specified)
const universalIcons = validIcons.filter((icon) => !icon.theme);
if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) {
// Heuristic: two theme-less icons → assume [0] = light, [1] = dark
return this.#proxyIconSrc(universalIcons[isDark ? 1 : 0].src);
return universalIcons[isDark ? 1 : 0].src;
}
if (universalIcons.length > 0) {
return this.#proxyIconSrc(universalIcons[0].src);
return universalIcons[0].src;
}
// 3. Last resort: use opposite-theme icon
return this.#proxyIconSrc(validIcons[0].src);
}
/**
* Route an icon src through the CORS proxy if it's an HTTPS URL.
* Data URIs are returned as-is.
*/
#proxyIconSrc(src: string): string {
if (src.startsWith('data:')) return src;
if (!this._proxyAvailable) return src;
return getProxiedUrlString(src);
return validIcons[0].src;
}
/**
* Get icon URL for an MCP server by its ID.
* Prefers the server's own icons (from MCP spec) and falls back
* to Google's favicon service.
* Returns null if server is not found.
* Returns the best icon from the MCP server's `icons` array
* (see MCP spec: spec.modelcontextprotocol.io).
* Returns null if no icon is available.
*/
getServerFavicon(serverId: string): string | null {
const server = this.getServerById(serverId);
@@ -497,7 +487,39 @@ class MCPStore {
}
}
return getFaviconUrl(server.url, this._proxyAvailable);
// Fallback: try favicon from root domain
const fallbackUrl = this.#getServerFaviconFallback(server.url);
if (fallbackUrl) {
return fallbackUrl;
}
return null;
}
/**
* Construct a fallback favicon URL from the MCP server URL.
* e.g. https://mcp.exa.ai/mcp -> https://exa.ai/favicon.ico
*/
#getServerFaviconFallback(serverUrl: string): string | null {
try {
const url = new URL(serverUrl);
const rootDomain = extractRootDomain(url);
if (!rootDomain) return null;
const origin = `${url.protocol}//${rootDomain}`;
const candidates = ['favicon.ico', 'favicon.svg', 'favicon.png'];
for (const path of candidates) {
const faviconUrl = `${origin}/${path}`;
if (this.#isValidIconUri(faviconUrl)) {
return faviconUrl;
}
}
} catch {
// Invalid URL, return null
}
return null;
}
isAnyServerLoading(): boolean {
@@ -33,12 +33,3 @@ export function buildProxiedHeaders(headers: Record<string, string>): Record<str
return proxiedHeaders;
}
/**
* Get a proxied URL string for use in fetch requests.
* @param targetUrl - The original URL to proxy
* @returns Proxied URL as string
*/
export function getProxiedUrlString(targetUrl: string): string {
return buildProxiedUrl(targetUrl).href;
}
@@ -1,34 +0,0 @@
/**
* Favicon utility functions for extracting favicons from URLs.
*/
import { getProxiedUrlString } from './cors-proxy';
import {
GOOGLE_FAVICON_BASE_URL,
DEFAULT_FAVICON_SIZE,
DOMAIN_SEPARATOR,
ROOT_DOMAIN_MIN_PARTS
} from '$lib/constants';
/**
* Gets a favicon URL for a given URL using Google's favicon service.
* Returns null if the URL is invalid.
*
* @param urlString - The URL to get the favicon for
* @returns The favicon URL or null if invalid
*/
export function getFaviconUrl(urlString: string, useProxy = true): string | null {
try {
const url = new URL(urlString);
const hostnameParts = url.hostname.split(DOMAIN_SEPARATOR);
const rootDomain =
hostnameParts.length >= ROOT_DOMAIN_MIN_PARTS
? hostnameParts.slice(-ROOT_DOMAIN_MIN_PARTS).join(DOMAIN_SEPARATOR)
: url.hostname;
const googleFaviconUrl = `${GOOGLE_FAVICON_BASE_URL}?domain=${rootDomain}&sz=${DEFAULT_FAVICON_SIZE}`;
return useProxy ? getProxiedUrlString(googleFaviconUrl) : googleFaviconUrl;
} catch {
return null;
}
}
+4 -4
View File
@@ -39,7 +39,10 @@ export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } fr
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
// CORS Proxy
export { buildProxiedUrl, getProxiedUrlString, buildProxiedHeaders } from './cors-proxy';
export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
// URL utilities
export { extractRootDomain, sanitizeExternalUrl } from './url';
// Conversation utilities
export { createMessageCountMap, getMessageCount } from './conversation-utils';
@@ -146,9 +149,6 @@ export { createBase64DataUrl } from './data-url';
// Header utilities
export { parseHeadersToArray, serializeHeaders } from './headers';
// Favicon utilities
export { getFaviconUrl } from './favicon';
// Agentic content utilities (structured section derivation)
export {
deriveAgenticSections,
+72
View File
@@ -0,0 +1,72 @@
import { TWO_PART_PUBLIC_SUFFIXES, WILDCARD_PUBLIC_SUFFIXES } from '$lib/constants';
import { UrlProtocol } from '$lib/enums';
/**
* Check whether a hostname looks like an IPv4 or IPv6 address.
*/
function isIpAddress(hostname: string): boolean {
if (hostname.includes(':')) return true;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return true;
return false;
}
/**
* Extract the registrable root domain from a URL.
*
* @example
* 'mcp.example.com' -> 'example.com'
* 'www.example.co.uk' -> 'example.co.uk'
* 'bar.foo.nom.br' -> 'bar.foo.nom.br'
* '192.168.1.1' -> null
* 'localhost' -> null
*/
export function extractRootDomain(url: URL): string | null {
const hostname = url.hostname.toLowerCase();
if (!hostname || isIpAddress(hostname)) return null;
const parts = hostname.split('.');
if (parts.length < 2) return null;
if (parts.length >= 3) {
const suffix2 = `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
if (TWO_PART_PUBLIC_SUFFIXES.has(suffix2)) {
return parts.slice(-3).join('.');
}
}
for (let i = 2; i <= parts.length; i++) {
const candidate = parts.slice(-i).join('.');
if (WILDCARD_PUBLIC_SUFFIXES.has(candidate)) {
if (parts.length === i + 1) {
return hostname;
}
return parts.slice(-(i + 2)).join('.');
}
}
return parts.slice(-2).join('.');
}
/**
* Sanitize an external URL string for safe use in an `<a href>`.
* Only allows http: and https: schemes. Returns `null` for anything else.
*/
export function sanitizeExternalUrl(raw: string): string | null {
try {
const url = new URL(raw);
if (url.protocol !== UrlProtocol.HTTP && url.protocol !== UrlProtocol.HTTPS) {
return null;
}
return url.href;
} catch {
return null;
}
}