webui: Add MCP CORS Proxy detection logic & UI (#20167)
* refactor: MCP store cleanup * feat: Add MCP proxy availability detection * fix: Sidebar icon * chore: update webui build output * chore: Formatting * chore: update webui build output * chore: Update package lock * chore: update webui build output * chore: update webui build output * chore: update webui build output
This commit is contained in:
committed by
GitHub
parent
d65c4f2dc9
commit
67a2209fab
@@ -6,6 +6,7 @@
|
||||
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
|
||||
import { UrlProtocol } from '$lib/enums';
|
||||
import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
@@ -62,14 +63,33 @@
|
||||
{/if}
|
||||
|
||||
{#if !isWebSocket && onUseProxyChange}
|
||||
<label class="mt-3 flex cursor-pointer items-center gap-2">
|
||||
<label
|
||||
class="mt-3 flex items-start gap-2"
|
||||
class:cursor-pointer={mcpStore.isProxyAvailable}
|
||||
class:opacity-80={!mcpStore.isProxyAvailable}
|
||||
>
|
||||
<Switch
|
||||
class="mt-1"
|
||||
id="use-proxy-{id}"
|
||||
checked={useProxy}
|
||||
disabled={!mcpStore.isProxyAvailable}
|
||||
onCheckedChange={(checked) => onUseProxyChange?.(checked)}
|
||||
/>
|
||||
|
||||
<span class="text-xs text-muted-foreground">Use llama-server proxy</span>
|
||||
<span>
|
||||
<span class="text-xs text-muted-foreground">Use llama-server proxy</span>
|
||||
|
||||
<br />
|
||||
|
||||
{#if !mcpStore.isProxyAvailable}
|
||||
<span class="inline-flex gap-0.75 text-xs text-muted-foreground/60"
|
||||
>(Run <pre>llama-server</pre>
|
||||
with
|
||||
<pre>--webui-mcp-proxy</pre>
|
||||
flag)</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { base } from '$app/paths';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
ToolCallType
|
||||
} from '$lib/enums';
|
||||
import {
|
||||
CORS_PROXY_ENDPOINT,
|
||||
DEFAULT_CACHE_TTL_MS,
|
||||
DEFAULT_MCP_CONFIG,
|
||||
EXPECTED_THEMED_ICON_PAIR_COUNT,
|
||||
@@ -78,165 +80,13 @@ import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { DatabaseMessageExtraMcpResource, McpServerOverride } from '$lib/types/database';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
|
||||
export function buildMcpClientConfig(
|
||||
cfg: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPClientConfig | undefined {
|
||||
return buildMcpClientConfigInternal(cfg, perChatOverrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to build MCP client config.
|
||||
* Kept as standalone function for external use and tests.
|
||||
*/
|
||||
export function buildMcpClientConfigInternal(
|
||||
cfg: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPClientConfig | undefined {
|
||||
const rawServers = parseServerSettings(cfg.mcpServers);
|
||||
if (!rawServers.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const servers: Record<string, MCPServerConfig> = {};
|
||||
|
||||
for (const [index, entry] of rawServers.entries()) {
|
||||
if (!checkServerEnabled(entry, perChatOverrides)) continue;
|
||||
const normalized = buildServerConfig(entry);
|
||||
if (normalized) servers[generateMcpServerId(entry.id, index)] = normalized;
|
||||
}
|
||||
|
||||
if (Object.keys(servers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
|
||||
capabilities: DEFAULT_MCP_CONFIG.capabilities,
|
||||
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
|
||||
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
|
||||
servers
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique server ID from an optional ID string or index.
|
||||
* @deprecated Use MCPStore.#generateServerId instead
|
||||
*/
|
||||
function generateMcpServerId(id: unknown, index: number): string {
|
||||
if (typeof id === 'string' && id.trim()) {
|
||||
return id.trim();
|
||||
}
|
||||
|
||||
return `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses raw server settings from config into MCPServerSettingsEntry array.
|
||||
* @deprecated Use MCPStore.#parseServerSettings instead
|
||||
*/
|
||||
function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
|
||||
if (!rawServers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
if (typeof rawServers === 'string') {
|
||||
const trimmed = rawServers.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.warn('[MCP] Failed to parse mcpServers JSON:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
parsed = rawServers;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.map((entry, index) => {
|
||||
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
|
||||
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
|
||||
|
||||
return {
|
||||
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
name: (entry as { name?: string })?.name,
|
||||
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
headers: headers || undefined,
|
||||
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
||||
} satisfies MCPServerSettingsEntry;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds server configuration from a settings entry.
|
||||
* @deprecated Use MCPStore.#buildServerConfig instead
|
||||
*/
|
||||
function buildServerConfig(
|
||||
entry: MCPServerSettingsEntry,
|
||||
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
|
||||
): MCPServerConfig | undefined {
|
||||
if (!entry?.url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let headers: Record<string, string> | undefined;
|
||||
if (entry.headers) {
|
||||
try {
|
||||
const parsed = JSON.parse(entry.headers);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
|
||||
headers = parsed as Record<string, string>;
|
||||
} catch {
|
||||
console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: entry.url,
|
||||
transport: detectMcpTransportFromUrl(entry.url),
|
||||
handshakeTimeoutMs: connectionTimeoutMs,
|
||||
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
|
||||
headers,
|
||||
useProxy: entry.useProxy
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a server is enabled, considering per-chat overrides.
|
||||
* @deprecated Use MCPStore.#checkServerEnabled instead
|
||||
*/
|
||||
function checkServerEnabled(
|
||||
server: MCPServerSettingsEntry,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
if (!server.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (perChatOverrides) {
|
||||
const override = perChatOverrides.find((o) => o.serverId === server.id);
|
||||
|
||||
return override?.enabled ?? false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
class MCPStore {
|
||||
private _isInitializing = $state(false);
|
||||
private _error = $state<string | null>(null);
|
||||
private _toolCount = $state(0);
|
||||
private _connectedServers = $state<string[]>([]);
|
||||
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
||||
private _proxyAvailable = $state(false);
|
||||
|
||||
private connections = new Map<string, MCPConnection>();
|
||||
private toolsIndex = new Map<string, string>();
|
||||
@@ -246,6 +96,29 @@ class MCPStore {
|
||||
private initPromise: Promise<boolean> | null = null;
|
||||
private activeFlowCount = 0;
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.probeProxy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes the CORS proxy endpoint to determine availability.
|
||||
* The endpoint is only registered when llama-server runs with --webui-mcp-proxy.
|
||||
*/
|
||||
async probeProxy(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${base}${CORS_PROXY_ENDPOINT}`, { method: 'HEAD' });
|
||||
this._proxyAvailable = response.status !== 404;
|
||||
} catch {
|
||||
this._proxyAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
get isProxyAvailable(): boolean {
|
||||
return this._proxyAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique server ID from an optional ID string or index.
|
||||
*/
|
||||
@@ -520,6 +393,7 @@ class MCPStore {
|
||||
|
||||
getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
const healthState = this.getHealthCheckState(server.id);
|
||||
|
||||
if (healthState?.status === HealthCheckStatus.SUCCESS)
|
||||
return (
|
||||
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
|
||||
@@ -603,6 +477,7 @@ class MCPStore {
|
||||
*/
|
||||
#proxyIconSrc(src: string): string {
|
||||
if (src.startsWith('data:')) return src;
|
||||
if (!this._proxyAvailable) return src;
|
||||
|
||||
return getProxiedUrlString(src);
|
||||
}
|
||||
@@ -629,7 +504,7 @@ class MCPStore {
|
||||
}
|
||||
}
|
||||
|
||||
return getFaviconUrl(server.url);
|
||||
return getFaviconUrl(server.url, this._proxyAvailable);
|
||||
}
|
||||
|
||||
isAnyServerLoading(): boolean {
|
||||
@@ -2072,6 +1947,7 @@ export const mcpIsInitializing = () => mcpStore.isInitializing;
|
||||
export const mcpIsInitialized = () => mcpStore.isInitialized;
|
||||
export const mcpError = () => mcpStore.error;
|
||||
export const mcpIsEnabled = () => mcpStore.isEnabled;
|
||||
export const mcpIsProxyAvailable = () => mcpStore.isProxyAvailable;
|
||||
export const mcpAvailableTools = () => mcpStore.availableTools;
|
||||
export const mcpConnectedServerCount = () => mcpStore.connectedServerCount;
|
||||
export const mcpConnectedServerNames = () => mcpStore.connectedServerNames;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
* @param urlString - The URL to get the favicon for
|
||||
* @returns The favicon URL or null if invalid
|
||||
*/
|
||||
export function getFaviconUrl(urlString: string): string | null {
|
||||
export function getFaviconUrl(urlString: string, useProxy = true): string | null {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const hostnameParts = url.hostname.split(DOMAIN_SEPARATOR);
|
||||
@@ -27,7 +27,7 @@ export function getFaviconUrl(urlString: string): string | null {
|
||||
: url.hostname;
|
||||
|
||||
const googleFaviconUrl = `${GOOGLE_FAVICON_BASE_URL}?domain=${rootDomain}&sz=${DEFAULT_FAVICON_SIZE}`;
|
||||
return getProxiedUrlString(googleFaviconUrl);
|
||||
return useProxy ? getProxiedUrlString(googleFaviconUrl) : googleFaviconUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
<Sidebar.Trigger
|
||||
class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen
|
||||
? 'md:left-[var(--sidebar-width)]'
|
||||
: ''}"
|
||||
: 'md:left-0!'}"
|
||||
style="translate: 1rem 1rem;"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user