refactor: Cleanup
This commit is contained in:
parent
c9f0eb1578
commit
374523be3d
Binary file not shown.
|
|
@ -26,8 +26,7 @@
|
||||||
import { mcpClient } from '$lib/clients';
|
import { mcpClient } from '$lib/clients';
|
||||||
import { ChatService } from '$lib/services';
|
import { ChatService } from '$lib/services';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { getAgenticConfig } from '$lib/utils/agentic';
|
import { getAgenticConfig, toAgenticMessages } from '$lib/utils';
|
||||||
import { toAgenticMessages } from '$lib/utils';
|
|
||||||
import type { AgenticMessage, AgenticToolCallList } from '$lib/types/agentic';
|
import type { AgenticMessage, AgenticToolCallList } from '$lib/types/agentic';
|
||||||
import type {
|
import type {
|
||||||
ApiChatCompletionToolCall,
|
ApiChatCompletionToolCall,
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ import {
|
||||||
normalizeModelName,
|
normalizeModelName,
|
||||||
filterByLeafNodeId,
|
filterByLeafNodeId,
|
||||||
findDescendantMessages,
|
findDescendantMessages,
|
||||||
findLeafNode
|
findLeafNode,
|
||||||
|
getAgenticConfig
|
||||||
} from '$lib/utils';
|
} from '$lib/utils';
|
||||||
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
|
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
|
||||||
import { getAgenticConfig } from '$lib/utils/agentic';
|
|
||||||
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
|
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
|
||||||
import { REASONING_TAGS } from '$lib/constants/agentic';
|
import { REASONING_TAGS } from '$lib/constants/agentic';
|
||||||
import type { ChatMessageTimings, ChatMessagePromptProgress } from '$lib/types/chat';
|
import type { ChatMessageTimings, ChatMessagePromptProgress } from '$lib/types/chat';
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import { toast } from 'svelte-sonner';
|
||||||
import { DatabaseService } from '$lib/services/database.service';
|
import { DatabaseService } from '$lib/services/database.service';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||||
import { getEnabledServersForConversation } from '$lib/utils/mcp';
|
|
||||||
import { mcpClient } from '$lib/clients/mcp.client';
|
import { mcpClient } from '$lib/clients/mcp.client';
|
||||||
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import type { McpServerOverride } from '$lib/types/database';
|
import type { McpServerOverride } from '$lib/types/database';
|
||||||
import { MessageRole } from '$lib/enums';
|
import { MessageRole } from '$lib/enums';
|
||||||
|
|
||||||
|
|
@ -182,7 +182,7 @@ export class ConversationsClient {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledServers = getEnabledServersForConversation(config(), mcpServerOverrides);
|
const enabledServers = mcpStore.getEnabledServersForConversation(mcpServerOverrides);
|
||||||
|
|
||||||
if (enabledServers.length === 0) {
|
if (enabledServers.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,158 @@ import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
|
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
|
||||||
import type { McpServerOverride } from '$lib/types/database';
|
import type { McpServerOverride } from '$lib/types/database';
|
||||||
import { MCPError } from '$lib/errors';
|
import { MCPError } from '$lib/errors';
|
||||||
import { buildMcpClientConfig, detectMcpTransportFromUrl } from '$lib/utils/mcp';
|
import { detectMcpTransportFromUrl } from '$lib/utils';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||||
|
import type { MCPServerConfig, MCPServerSettingsEntry } from '$lib/types';
|
||||||
|
import type { SettingsConfigType } from '$lib/types/settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a valid MCP server ID from user input.
|
||||||
|
* Returns the trimmed ID if valid, otherwise generates 'server-{index+1}'.
|
||||||
|
*/
|
||||||
|
function generateMcpServerId(id: unknown, index: number): string {
|
||||||
|
if (typeof id === 'string' && id.trim()) {
|
||||||
|
return id.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${MCP_SERVER_ID_PREFIX}${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses MCP server settings from a JSON string or array.
|
||||||
|
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
|
||||||
|
* @param rawServers - The raw servers to parse
|
||||||
|
* @returns An empty array if the input is invalid.
|
||||||
|
*/
|
||||||
|
function parseMcpServerSettings(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, ignoring value:', 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,
|
||||||
|
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.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 for the current chat.
|
||||||
|
* Server must be available (server.enabled) AND have a per-chat override enabling it.
|
||||||
|
* Pure helper function - no side effects.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build capabilities info from server and client capabilities
|
* Build capabilities info from server and client capabilities
|
||||||
|
|
@ -613,8 +762,6 @@ export class MCPClient {
|
||||||
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
|
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpStore.incrementServerUsage(serverName);
|
|
||||||
|
|
||||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||||
|
|
||||||
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
|
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@
|
||||||
import { mcpClient } from '$lib/clients/mcp.client';
|
import { mcpClient } from '$lib/clients/mcp.client';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
|
import { getFaviconUrl, debounce } from '$lib/utils';
|
||||||
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
|
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
|
||||||
import { SearchInput } from '$lib/components/app';
|
import { SearchInput } from '$lib/components/app';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -65,11 +64,9 @@
|
||||||
|
|
||||||
function getServerLabel(serverId: string): string {
|
function getServerLabel(serverId: string): string {
|
||||||
const server = serverSettingsMap.get(serverId);
|
const server = serverSettingsMap.get(serverId);
|
||||||
|
|
||||||
if (!server) return serverId;
|
if (!server) return serverId;
|
||||||
|
|
||||||
const healthState = mcpStore.getHealthCheckState(serverId);
|
return mcpStore.getServerLabel(server);
|
||||||
return getMcpServerLabel(server, healthState);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
|
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
|
||||||
import { AgenticSectionType } from '$lib/enums';
|
import { AgenticSectionType } from '$lib/enums';
|
||||||
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS } from '$lib/constants/agentic';
|
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS } from '$lib/constants/agentic';
|
||||||
import { formatJsonPretty } from '$lib/utils/formatters';
|
import { formatJsonPretty } from '$lib/utils';
|
||||||
import type { DatabaseMessage } from '$lib/types/database';
|
import type { DatabaseMessage } from '$lib/types/database';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
import { ChatFormInputArea, DialogConfirmation } from '$lib/components/app';
|
import { ChatFormInputArea, DialogConfirmation } from '$lib/components/app';
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
|
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editedContent: string;
|
editedContent: string;
|
||||||
|
|
@ -104,7 +105,6 @@
|
||||||
async function handleFilesAdd(files: File[]) {
|
async function handleFilesAdd(files: File[]) {
|
||||||
if (!onEditedUploadedFilesChange) return;
|
if (!onEditedUploadedFilesChange) return;
|
||||||
|
|
||||||
const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
|
|
||||||
const processed = await processFilesToChatUploaded(files);
|
const processed = await processFilesToChatUploaded(files);
|
||||||
|
|
||||||
onEditedUploadedFilesChange([...editedUploadedFiles, processed].flat());
|
onEditedUploadedFilesChange([...editedUploadedFiles, processed].flat());
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { ChatMessageStatsView } from '$lib/enums';
|
import { ChatMessageStatsView } from '$lib/enums';
|
||||||
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
|
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
|
||||||
import { formatPerformanceTime } from '$lib/utils/formatters';
|
import { formatPerformanceTime } from '$lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
predictedTokens?: number;
|
predictedTokens?: number;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import Input from '$lib/components/ui/input/input.svelte';
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { getPreviewText } from '$lib/utils/text';
|
import { getPreviewText } from '$lib/utils';
|
||||||
import ChatSidebarActions from './ChatSidebarActions.svelte';
|
import ChatSidebarActions from './ChatSidebarActions.svelte';
|
||||||
|
|
||||||
const sidebar = Sidebar.useSidebar();
|
const sidebar = Sidebar.useSidebar();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Card } from '$lib/components/ui/card';
|
import { Card } from '$lib/components/ui/card';
|
||||||
import type { DatabaseMessageExtraMcpPrompt, MCPServerSettingsEntry } from '$lib/types';
|
import type { DatabaseMessageExtraMcpPrompt, MCPServerSettingsEntry } from '$lib/types';
|
||||||
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
|
import { getFaviconUrl } from '$lib/utils';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
|
@ -51,8 +51,7 @@
|
||||||
const server = serverSettingsMap.get(prompt.serverName);
|
const server = serverSettingsMap.get(prompt.serverName);
|
||||||
if (!server) return prompt.serverName;
|
if (!server) return prompt.serverName;
|
||||||
|
|
||||||
const healthState = mcpStore.getHealthCheckState(server.id);
|
return mcpStore.getServerLabel(server);
|
||||||
return getMcpServerLabel(server, healthState);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentParts = $derived.by((): ContentPart[] => {
|
let contentParts = $derived.by((): ContentPart[] => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { cn } from '$lib/components/ui/utils';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { getFaviconUrl } from '$lib/utils/mcp';
|
import { getFaviconUrl } from '$lib/utils';
|
||||||
import { HealthCheckStatus } from '$lib/enums';
|
import { HealthCheckStatus } from '$lib/enums';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { SearchableDropdownMenu } from '$lib/components/app';
|
import { SearchableDropdownMenu } from '$lib/components/app';
|
||||||
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
|
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { getMcpServerLabel, getFaviconUrl } from '$lib/utils/mcp';
|
import { getFaviconUrl } from '$lib/utils';
|
||||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||||
import { HealthCheckStatus } from '$lib/enums';
|
import { HealthCheckStatus } from '$lib/enums';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
);
|
);
|
||||||
|
|
||||||
function getServerLabel(server: MCPServerSettingsEntry): string {
|
function getServerLabel(server: MCPServerSettingsEntry): string {
|
||||||
return getMcpServerLabel(server, mcpStore.getHealthCheckState(server.id));
|
return mcpStore.getServerLabel(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDropdownOpen(open: boolean) {
|
function handleDropdownOpen(open: boolean) {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
import { cn } from '$lib/components/ui/utils';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
import type { MCPConnectionLog } from '$lib/types';
|
import type { MCPConnectionLog } from '$lib/types';
|
||||||
import { formatTime } from '$lib/utils/formatters';
|
import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
|
||||||
import { getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils/mcp';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logs: MCPConnectionLog[];
|
logs: MCPConnectionLog[];
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
|
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
|
||||||
import { getMcpServerLabel } from '$lib/utils/mcp';
|
|
||||||
import { HealthCheckStatus } from '$lib/enums';
|
import { HealthCheckStatus } from '$lib/enums';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { mcpClient } from '$lib/clients/mcp.client';
|
import { mcpClient } from '$lib/clients/mcp.client';
|
||||||
|
|
@ -27,7 +26,7 @@
|
||||||
let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
|
let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
|
||||||
|
|
||||||
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
|
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
|
||||||
let displayName = $derived(getMcpServerLabel(server, healthState));
|
let displayName = $derived(mcpStore.getServerLabel(server));
|
||||||
let isIdle = $derived(healthState.status === HealthCheckStatus.Idle);
|
let isIdle = $derived(healthState.status === HealthCheckStatus.Idle);
|
||||||
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.Connecting);
|
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.Connecting);
|
||||||
let isConnected = $derived(healthState.status === HealthCheckStatus.Success);
|
let isConnected = $derived(healthState.status === HealthCheckStatus.Success);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { KeyValuePairs } from '$lib/components/app';
|
import { KeyValuePairs } from '$lib/components/app';
|
||||||
import type { KeyValuePair } from '$lib/types';
|
import type { KeyValuePair } from '$lib/types';
|
||||||
import { parseHeadersToArray, serializeHeaders } from '$lib/utils/mcp';
|
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { Plus, X } from '@lucide/svelte';
|
import { Plus, X } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { getFaviconUrl } from '$lib/utils/mcp';
|
import { getFaviconUrl } from '$lib/utils';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { McpServerCard } from '$lib/components/app/mcp/McpServerCard';
|
import { McpServerCard } from '$lib/components/app/mcp/McpServerCard';
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,13 @@
|
||||||
import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
|
import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
|
||||||
import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
|
import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
|
||||||
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
|
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
|
||||||
import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
|
import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
|
||||||
import '$styles/katex-custom.scss';
|
import '$styles/katex-custom.scss';
|
||||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import CodePreviewDialog from './CodePreviewDialog.svelte';
|
import CodePreviewDialog from './CodePreviewDialog.svelte';
|
||||||
import type { DatabaseMessage } from '$lib/types/database';
|
import type { DatabaseMessage } from '$lib/types/database';
|
||||||
import { getImageErrorFallbackHtml } from '$lib/utils/image-error-fallback';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message?: DatabaseMessage;
|
message?: DatabaseMessage;
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,5 @@ export const DEFAULT_MCP_CONFIG = {
|
||||||
requestTimeoutSeconds: 300, // 5 minutes for long-running tools
|
requestTimeoutSeconds: 300, // 5 minutes for long-running tools
|
||||||
connectionTimeoutMs: 10_000 // 10 seconds for connection establishment
|
connectionTimeoutMs: 10_000 // 10 seconds for connection establishment
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const MCP_SERVER_ID_PREFIX = 'LlamaCpp-WebUI-MCP-Server-';
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import type { AgenticFlowParams, AgenticFlowResult } from '$lib/clients';
|
import type { AgenticFlowParams, AgenticFlowResult } from '$lib/clients';
|
||||||
import type { AgenticSession } from '$lib/types/agentic';
|
import type { AgenticSession } from '$lib/types/agentic';
|
||||||
|
import { agenticClient } from '$lib/clients/agentic.client';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AgenticFlowCallbacks,
|
AgenticFlowCallbacks,
|
||||||
|
|
@ -56,8 +57,8 @@ class AgenticStore {
|
||||||
*/
|
*/
|
||||||
private _sessions = $state<Map<string, AgenticSession>>(new Map());
|
private _sessions = $state<Map<string, AgenticSession>>(new Map());
|
||||||
|
|
||||||
/** Reference to the client (lazy loaded to avoid circular dependency) */
|
/** Reference to the client */
|
||||||
private _client: typeof import('$lib/clients/agentic.client').agenticClient | null = null;
|
private _client = agenticClient;
|
||||||
|
|
||||||
private get client() {
|
private get client() {
|
||||||
return this._client;
|
return this._client;
|
||||||
|
|
@ -65,19 +66,18 @@ class AgenticStore {
|
||||||
|
|
||||||
/** Check if store is ready (client initialized) */
|
/** Check if store is ready (client initialized) */
|
||||||
get isReady(): boolean {
|
get isReady(): boolean {
|
||||||
return this._client !== null;
|
return this._initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _initialized = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the store by wiring up to the client.
|
* Initialize the store by wiring up to the client.
|
||||||
* Must be called once after app startup.
|
* Must be called once after app startup.
|
||||||
*/
|
*/
|
||||||
async init(): Promise<void> {
|
init(): void {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
if (this._client) return; // Already initialized
|
if (this._initialized) return; // Already initialized
|
||||||
|
|
||||||
const { agenticClient } = await import('$lib/clients/agentic.client');
|
|
||||||
this._client = agenticClient;
|
|
||||||
|
|
||||||
agenticClient.setStoreCallbacks({
|
agenticClient.setStoreCallbacks({
|
||||||
setRunning: (convId, running) => this.updateSession(convId, { isRunning: running }),
|
setRunning: (convId, running) => this.updateSession(convId, { isRunning: running }),
|
||||||
|
|
@ -87,6 +87,8 @@ class AgenticStore {
|
||||||
setStreamingToolCall: (convId, tc) => this.updateSession(convId, { streamingToolCall: tc }),
|
setStreamingToolCall: (convId, tc) => this.updateSession(convId, { streamingToolCall: tc }),
|
||||||
clearStreamingToolCall: (convId) => this.updateSession(convId, { streamingToolCall: null })
|
clearStreamingToolCall: (convId) => this.updateSession(convId, { streamingToolCall: null })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -19,45 +19,19 @@
|
||||||
* @see MCPService in services/mcp.ts for protocol operations
|
* @see MCPService in services/mcp.ts for protocol operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mcpClient } from '$lib/clients/mcp.client';
|
import { mcpClient, buildMcpClientConfig } from '$lib/clients/mcp.client';
|
||||||
import type {
|
import type {
|
||||||
HealthCheckState,
|
HealthCheckState,
|
||||||
MCPServerSettingsEntry,
|
MCPServerSettingsEntry,
|
||||||
McpServerUsageStats,
|
|
||||||
MCPPromptInfo,
|
MCPPromptInfo,
|
||||||
GetPromptResult
|
GetPromptResult
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import type { McpServerOverride } from '$lib/types/database';
|
import type { McpServerOverride } from '$lib/types/database';
|
||||||
import { buildMcpClientConfig, parseMcpServerSettings, getMcpServerLabel } from '$lib/utils/mcp';
|
import { parseMcpServerSettings } from '$lib/utils';
|
||||||
import { HealthCheckStatus } from '$lib/enums';
|
import { HealthCheckStatus } from '$lib/enums';
|
||||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses MCP server usage stats from settings.
|
|
||||||
* @param rawStats - The raw stats to parse
|
|
||||||
* @returns MCP server usage stats or empty object if invalid
|
|
||||||
*/
|
|
||||||
function parseMcpServerUsageStats(rawStats: unknown): McpServerUsageStats {
|
|
||||||
if (!rawStats) return {};
|
|
||||||
|
|
||||||
if (typeof rawStats === 'string') {
|
|
||||||
const trimmed = rawStats.trim();
|
|
||||||
if (!trimmed) return {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
||||||
return parsed as McpServerUsageStats;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.warn('[MCP] Failed to parse mcpServerUsageStats JSON, ignoring value');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
class MCPStore {
|
class MCPStore {
|
||||||
private _isInitializing = $state(false);
|
private _isInitializing = $state(false);
|
||||||
private _error = $state<string | null>(null);
|
private _error = $state<string | null>(null);
|
||||||
|
|
@ -178,6 +152,21 @@ class MCPStore {
|
||||||
return parseMcpServerSettings(config().mcpServers);
|
return parseMcpServerSettings(config().mcpServers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a display label for an MCP server.
|
||||||
|
* Automatically fetches health state from store.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.url;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if any server is still loading (idle or connecting).
|
* Check if any server is still loading (idle or connecting).
|
||||||
*/
|
*/
|
||||||
|
|
@ -205,8 +194,8 @@ class MCPStore {
|
||||||
|
|
||||||
// Sort alphabetically by display label once all health checks are done
|
// Sort alphabetically by display label once all health checks are done
|
||||||
return [...servers].sort((a, b) => {
|
return [...servers].sort((a, b) => {
|
||||||
const labelA = getMcpServerLabel(a, this.getHealthCheckState(a.id));
|
const labelA = this.getServerLabel(a);
|
||||||
const labelB = getMcpServerLabel(b, this.getHealthCheckState(b.id));
|
const labelB = this.getServerLabel(b);
|
||||||
return labelA.localeCompare(labelB);
|
return labelA.localeCompare(labelB);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -266,33 +255,24 @@ class MCPStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Gets enabled MCP servers for a conversation based on per-chat overrides.
|
||||||
* Server Usage Stats
|
* Returns servers that are both globally enabled AND enabled for this chat.
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
|
getEnabledServersForConversation(
|
||||||
|
perChatOverrides?: McpServerOverride[]
|
||||||
|
): MCPServerSettingsEntry[] {
|
||||||
|
if (!perChatOverrides?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
const allServers = this.getServers();
|
||||||
* Get parsed usage stats for all servers
|
|
||||||
*/
|
|
||||||
getUsageStats(): McpServerUsageStats {
|
|
||||||
return parseMcpServerUsageStats(config().mcpServerUsageStats);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return allServers.filter((server) => {
|
||||||
* Get usage count for a specific server
|
if (!server.enabled) return false;
|
||||||
*/
|
const override = perChatOverrides.find((o) => o.serverId === server.id);
|
||||||
getServerUsageCount(serverId: string): number {
|
|
||||||
const stats = this.getUsageStats();
|
|
||||||
return stats[serverId] || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return override?.enabled ?? false;
|
||||||
* Increment usage count for a server
|
});
|
||||||
*/
|
|
||||||
incrementServerUsage(serverId: string): void {
|
|
||||||
const stats = this.getUsageStats();
|
|
||||||
stats[serverId] = (stats[serverId] || 0) + 1;
|
|
||||||
settingsStore.updateConfig('mcpServerUsageStats', JSON.stringify(stats));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,6 @@ export type {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
MCPClientConfig,
|
MCPClientConfig,
|
||||||
MCPServerSettingsEntry,
|
MCPServerSettingsEntry,
|
||||||
McpServerUsageStats,
|
|
||||||
MCPToolCall,
|
MCPToolCall,
|
||||||
OpenAIToolDefinition,
|
OpenAIToolDefinition,
|
||||||
ServerStatus,
|
ServerStatus,
|
||||||
|
|
|
||||||
|
|
@ -232,8 +232,6 @@ export interface ServerStatus {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpServerUsageStats = Record<string, number>;
|
|
||||||
|
|
||||||
export interface MCPServerConnectionConfig {
|
export interface MCPServerConnectionConfig {
|
||||||
name: string;
|
name: string;
|
||||||
server: MCPServerConfig;
|
server: MCPServerConfig;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Favicon utility functions for extracting favicons from URLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
const hostnameParts = url.hostname.split('.');
|
||||||
|
const rootDomain = hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : url.hostname;
|
||||||
|
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Header utilities for parsing and serializing HTTP headers.
|
||||||
|
* Generic utilities not specific to MCP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
@ -64,7 +64,14 @@ export {
|
||||||
} from './file-type';
|
} from './file-type';
|
||||||
|
|
||||||
// Formatting utilities
|
// Formatting utilities
|
||||||
export { formatFileSize, formatParameters, formatNumber } from './formatters';
|
export {
|
||||||
|
formatFileSize,
|
||||||
|
formatParameters,
|
||||||
|
formatNumber,
|
||||||
|
formatJsonPretty,
|
||||||
|
formatTime,
|
||||||
|
formatPerformanceTime
|
||||||
|
} from './formatters';
|
||||||
|
|
||||||
// IME utilities
|
// IME utilities
|
||||||
export { isIMEComposing } from './is-ime-composing';
|
export { isIMEComposing } from './is-ime-composing';
|
||||||
|
|
@ -95,7 +102,30 @@ export { getLanguageFromFilename } from './syntax-highlight-language';
|
||||||
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
|
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
|
||||||
|
|
||||||
// Agentic utilities
|
// Agentic utilities
|
||||||
export { toAgenticMessages } from './agentic';
|
export { toAgenticMessages, getAgenticConfig } from './agentic';
|
||||||
|
|
||||||
// Base64 utilities
|
// Base64 utilities
|
||||||
export { decodeBase64 } from './base64';
|
export { decodeBase64 } from './base64';
|
||||||
|
|
||||||
|
// Chat stream utilities
|
||||||
|
export { mergeToolCallDeltas, extractModelName } from './chat-stream';
|
||||||
|
|
||||||
|
// Debounce utilities
|
||||||
|
export { debounce } from './debounce';
|
||||||
|
|
||||||
|
// Image error fallback utilities
|
||||||
|
export { getImageErrorFallbackHtml } from './image-error-fallback';
|
||||||
|
|
||||||
|
// MCP utilities
|
||||||
|
export {
|
||||||
|
detectMcpTransportFromUrl,
|
||||||
|
parseMcpServerSettings,
|
||||||
|
getMcpLogLevelIcon,
|
||||||
|
getMcpLogLevelClass
|
||||||
|
} from './mcp';
|
||||||
|
|
||||||
|
// Header utilities
|
||||||
|
export { parseHeadersToArray, serializeHeaders } from './headers';
|
||||||
|
|
||||||
|
// Favicon utilities
|
||||||
|
export { getFaviconUrl } from './favicon';
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import type {
|
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||||
HealthCheckState,
|
import { MCPTransportType, MCPLogLevel } from '$lib/enums';
|
||||||
MCPClientConfig,
|
|
||||||
MCPServerConfig,
|
|
||||||
MCPServerSettingsEntry
|
|
||||||
} from '$lib/types';
|
|
||||||
import type { SettingsConfigType } from '$lib/types/settings';
|
|
||||||
import type { McpServerOverride } from '$lib/types/database';
|
|
||||||
import { MCPTransportType, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
|
|
||||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||||
import { Info, AlertTriangle, XCircle } from '@lucide/svelte';
|
import { Info, AlertTriangle, XCircle } from '@lucide/svelte';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
@ -23,90 +16,6 @@ export function detectMcpTransportFromUrl(url: string): MCPTransportType {
|
||||||
: MCPTransportType.StreamableHttp;
|
: 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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a display label for an MCP server based on health state.
|
|
||||||
*/
|
|
||||||
export function getMcpServerLabel(
|
|
||||||
server: MCPServerSettingsEntry,
|
|
||||||
healthState?: HealthCheckState
|
|
||||||
): string {
|
|
||||||
if (healthState?.status === HealthCheckStatus.Success) {
|
|
||||||
return (
|
|
||||||
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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.
|
* Parses MCP server settings from a JSON string or array.
|
||||||
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
|
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
|
||||||
|
|
@ -136,9 +45,13 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
|
||||||
return parsed.map((entry, index) => {
|
return parsed.map((entry, index) => {
|
||||||
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
|
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
|
||||||
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
|
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
|
||||||
|
const id =
|
||||||
|
typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim()
|
||||||
|
? (entry as { id: string }).id.trim()
|
||||||
|
: `server-${index + 1}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
|
id,
|
||||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||||
url,
|
url,
|
||||||
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||||
|
|
@ -147,122 +60,6 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 for the current chat.
|
|
||||||
* Server must be available (server.enabled) AND have a per-chat override enabling it.
|
|
||||||
* Pure helper function - no side effects.
|
|
||||||
*/
|
|
||||||
export function checkServerEnabled(
|
|
||||||
server: MCPServerSettingsEntry,
|
|
||||||
perChatOverrides?: McpServerOverride[]
|
|
||||||
): boolean {
|
|
||||||
// Server must be available in settings first
|
|
||||||
if (!server.enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check if it's enabled for this chat via override
|
|
||||||
if (perChatOverrides) {
|
|
||||||
const override = perChatOverrides.find((o) => o.serverId === server.id);
|
|
||||||
return override?.enabled ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets enabled MCP servers for a conversation based on per-chat overrides.
|
|
||||||
* Returns servers that are both globally enabled AND enabled for this chat.
|
|
||||||
* @param config - Global settings configuration
|
|
||||||
* @param perChatOverrides - Per-chat server overrides
|
|
||||||
* @returns Array of enabled server settings entries
|
|
||||||
*/
|
|
||||||
export function getEnabledServersForConversation(
|
|
||||||
config: SettingsConfigType,
|
|
||||||
perChatOverrides?: McpServerOverride[]
|
|
||||||
): MCPServerSettingsEntry[] {
|
|
||||||
if (!perChatOverrides?.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const allServers = parseMcpServerSettings(config.mcpServers);
|
|
||||||
return allServers.filter((server) => checkServerEnabled(server, perChatOverrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the appropriate icon component for a log level
|
* Get the appropriate icon component for a log level
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue