llama.cpp/tools/server/webui/src/lib/utils/mcp.ts

290 lines
8.1 KiB
TypeScript

import type { MCPClientConfig, MCPServerConfig, MCPServerSettingsEntry } from '$lib/types';
import type { SettingsConfigType } from '$lib/types/settings';
import type { McpServerOverride } from '$lib/types/database';
import { MCPTransportType, MCPLogLevel } from '$lib/enums';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { normalizePositiveNumber } from '$lib/utils/number';
import { Info, AlertTriangle, XCircle } from '@lucide/svelte';
import type { Component } from 'svelte';
/**
* Detects the MCP transport type from a URL.
* WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'.
*/
export function detectMcpTransportFromUrl(url: string): MCPTransportType {
const normalized = url.trim().toLowerCase();
return normalized.startsWith('ws://') || normalized.startsWith('wss://')
? MCPTransportType.Websocket
: MCPTransportType.StreamableHttp;
}
/**
* Generates a valid MCP server ID from user input.
* Returns the trimmed ID if valid, otherwise generates 'server-{index+1}'.
*/
export function generateMcpServerId(id: unknown, index: number): string {
if (typeof id === 'string' && id.trim()) {
return id.trim();
}
return `server-${index + 1}`;
}
/**
* Extracts a human-readable server name from a URL.
* Strips common prefixes like 'www.' and 'mcp.' and capitalizes the result.
*/
export function extractServerNameFromUrl(url: string): string {
try {
const parsedUrl = new URL(url);
const host = parsedUrl.hostname.replace(/^(www\.|mcp\.)/, '');
const name = host.split('.')[0] || 'Unknown';
return name.charAt(0).toUpperCase() + name.slice(1);
} catch {
return 'New Server';
}
}
/**
* Gets a display name for an MCP server.
* Returns server.name if set, otherwise extracts name from URL.
*/
export function getServerDisplayName(server: MCPServerSettingsEntry): string {
if (server.name) return server.name;
return extractServerNameFromUrl(server.url);
}
/**
* Gets a favicon URL for an MCP server using Google's favicon service.
* Returns null if the URL is invalid.
*/
export function getFaviconUrl(serverUrl: string): string | null {
try {
const parsedUrl = new URL(serverUrl);
const hostnameParts = parsedUrl.hostname.split('.');
const rootDomain =
hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : parsedUrl.hostname;
return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`;
} catch {
return null;
}
}
/**
* Parses a JSON string of headers into an array of key-value pairs.
* Returns empty array if the JSON is invalid or empty.
*/
export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] {
if (!headersJson?.trim()) return [];
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([key, value]) => ({
key,
value: String(value)
}));
}
} catch {
return [];
}
return [];
}
/**
* Serializes an array of header key-value pairs to a JSON string.
* Filters out pairs with empty keys and returns empty string if no valid pairs.
*/
export function serializeHeaders(pairs: { key: string; value: string }[]): string {
const validPairs = pairs.filter((p) => p.key.trim());
if (validPairs.length === 0) return '';
const obj: Record<string, string> = {};
for (const pair of validPairs) {
obj[pair.key.trim()] = pair.value;
}
return JSON.stringify(obj);
}
/**
* Parses MCP server settings from a JSON string or array.
* @param rawServers - The raw servers to parse
* @param fallbackRequestTimeoutSeconds - The fallback request timeout seconds
* @returns An empty array if the input is invalid.
*/
export function parseMcpServerSettings(
rawServers: unknown,
fallbackRequestTimeoutSeconds = DEFAULT_MCP_CONFIG.requestTimeoutSeconds
): 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, ignoring value:', error);
return [];
}
} else {
parsed = rawServers;
}
if (!Array.isArray(parsed)) return [];
return parsed.map((entry, index) => {
const requestTimeoutSeconds = normalizePositiveNumber(
(entry as { requestTimeoutSeconds?: unknown })?.requestTimeoutSeconds,
fallbackRequestTimeoutSeconds
);
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,
requestTimeoutSeconds,
headers: headers || undefined
} satisfies MCPServerSettingsEntry;
});
}
/**
* Builds an MCP server configuration from a server settings entry.
* @param entry - The server settings entry to build the configuration from
* @param connectionTimeoutMs - The connection timeout in milliseconds
* @returns The built server configuration, or undefined if the entry is invalid
*/
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, ignoring:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers
};
}
/**
* Checks if a server is enabled considering per-chat overrides.
* Per-chat override takes precedence over global setting.
* Pure helper function - no side effects.
*/
export function checkServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
if (perChatOverrides) {
const override = perChatOverrides.find((o) => o.serverId === server.id);
if (override !== undefined) {
return override.enabled;
}
}
return server.enabled;
}
/**
* Builds MCP client configuration from settings.
* Returns undefined if no valid servers are configured.
* @param config - Global settings configuration
* @param perChatOverrides - Optional per-chat server overrides
*/
export function buildMcpClientConfig(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(config.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
};
}
/**
* Get the appropriate icon component for a log level
*
* @param level - MCP log level
* @returns Lucide icon component
*/
export function getMcpLogLevelIcon(level: MCPLogLevel): Component {
switch (level) {
case MCPLogLevel.Error:
return XCircle;
case MCPLogLevel.Warn:
return AlertTriangle;
default:
return Info;
}
}
/**
* Get the appropriate CSS class for a log level
*
* @param level - MCP log level
* @returns Tailwind CSS class string
*/
export function getMcpLogLevelClass(level: MCPLogLevel): string {
switch (level) {
case MCPLogLevel.Error:
return 'text-destructive';
case MCPLogLevel.Warn:
return 'text-yellow-600 dark:text-yellow-500';
default:
return 'text-muted-foreground';
}
}