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:
committed by
GitHub
parent
f08f20a0e3
commit
e3e3f8e46a
File diff suppressed because one or more lines are too long
+1082
-1079
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -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
|
||||
|
||||
+13
-17
@@ -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);
|
||||
|
||||
+12
-39
@@ -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" />
|
||||
|
||||
+11
-29
@@ -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';
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user