diff --git a/tools/server/webui/src/lib/config/agentic.ts b/tools/server/webui/src/lib/config/agentic.ts index 6aed7a3728..fd94d4e976 100644 --- a/tools/server/webui/src/lib/config/agentic.ts +++ b/tools/server/webui/src/lib/config/agentic.ts @@ -1,14 +1,20 @@ import { hasEnabledMcpServers } from './mcp'; import type { SettingsConfigType } from '$lib/types/settings'; import type { AgenticConfig } from '$lib/types/agentic'; +import type { McpServerOverride } from '$lib/types/database'; import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic'; import { normalizePositiveNumber } from '$lib/utils/number'; /** * Gets the current agentic configuration. * Automatically disables agentic mode if no MCP servers are configured. + * @param settings - Global settings configuration + * @param perChatOverrides - Optional per-chat MCP server overrides */ -export function getAgenticConfig(settings: SettingsConfigType): AgenticConfig { +export function getAgenticConfig( + settings: SettingsConfigType, + perChatOverrides?: McpServerOverride[] +): AgenticConfig { const maxTurns = normalizePositiveNumber( settings.agenticMaxTurns, DEFAULT_AGENTIC_CONFIG.maxTurns @@ -23,7 +29,7 @@ export function getAgenticConfig(settings: SettingsConfigType): AgenticConfig { : DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn; return { - enabled: hasEnabledMcpServers(settings) && DEFAULT_AGENTIC_CONFIG.enabled, + enabled: hasEnabledMcpServers(settings, perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled, maxTurns, maxToolPreviewLines, filterReasoningAfterFirstTurn diff --git a/tools/server/webui/src/lib/config/mcp.ts b/tools/server/webui/src/lib/config/mcp.ts index b85851d06f..18f465ab3e 100644 --- a/tools/server/webui/src/lib/config/mcp.ts +++ b/tools/server/webui/src/lib/config/mcp.ts @@ -1,5 +1,6 @@ import type { MCPClientConfig, MCPServerConfig, MCPServerSettingsEntry } from '$lib/types/mcp'; import type { SettingsConfigType } from '$lib/types/settings'; +import type { McpServerOverride } from '$lib/types/database'; import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; import { detectMcpTransportFromUrl, generateMcpServerId } from '$lib/utils/mcp'; import { normalizePositiveNumber } from '$lib/utils/number'; @@ -76,11 +77,33 @@ function buildServerConfig( }; } +/** + * Checks if a server is enabled considering per-chat overrides. + * Per-chat override takes precedence over global setting. + */ +function isServerEnabled( + 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): MCPClientConfig | undefined { +export function buildMcpClientConfig( + config: SettingsConfigType, + perChatOverrides?: McpServerOverride[] +): MCPClientConfig | undefined { const rawServers = parseMcpServerSettings(config.mcpServers); if (!rawServers.length) { @@ -89,7 +112,7 @@ export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfi const servers: Record = {}; for (const [index, entry] of rawServers.entries()) { - if (!entry.enabled) continue; + if (!isServerEnabled(entry, perChatOverrides)) continue; const normalized = buildServerConfig(entry); if (normalized) { @@ -110,6 +133,55 @@ export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfi }; } -export function hasEnabledMcpServers(config: SettingsConfigType): boolean { - return Boolean(buildMcpClientConfig(config)); +export function hasEnabledMcpServers( + config: SettingsConfigType, + perChatOverrides?: McpServerOverride[] +): boolean { + return Boolean(buildMcpClientConfig(config, perChatOverrides)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// MCP Server Usage Stats +// ───────────────────────────────────────────────────────────────────────────── + +export type McpServerUsageStats = Record; + +/** + * Parse MCP server usage stats from settings. + */ +export 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 {}; +} + +/** + * Get usage count for a specific server. + */ +export function getMcpServerUsageCount(config: SettingsConfigType, serverId: string): number { + const stats = parseMcpServerUsageStats(config.mcpServerUsageStats); + return stats[serverId] || 0; +} + +/** + * Increment usage count for a server and return updated stats JSON. + */ +export function incrementMcpServerUsage(config: SettingsConfigType, serverId: string): string { + const stats = parseMcpServerUsageStats(config.mcpServerUsageStats); + stats[serverId] = (stats[serverId] || 0) + 1; + return JSON.stringify(stats); } diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts index 15d8a351f6..de4685fe24 100644 --- a/tools/server/webui/src/lib/constants/settings-config.ts +++ b/tools/server/webui/src/lib/constants/settings-config.ts @@ -19,9 +19,11 @@ export const SETTING_CONFIG_DEFAULT: Record = autoShowSidebarOnNewChat: true, autoMicOnEmpty: false, mcpServers: '[]', + mcpServerUsageStats: '{}', // JSON object: { [serverId]: usageCount } agenticMaxTurns: 10, agenticMaxToolPreviewLines: 25, agenticFilterReasoningAfterFirstTurn: true, + showToolCallInProgress: false, // make sure these default values are in sync with `common.h` samplers: 'top_k;typ_p;top_p;min_p;temperature', backend_sampling: false, @@ -113,12 +115,16 @@ export const SETTING_CONFIG_INFO: Record = { 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.', mcpServers: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.', + mcpServerUsageStats: + 'Usage statistics for MCP servers. Tracks how many times tools from each server have been used.', agenticMaxTurns: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).', agenticMaxToolPreviewLines: 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.', agenticFilterReasoningAfterFirstTurn: 'Only show reasoning from the first agentic turn. When disabled, reasoning from all turns is merged in one (WebUI limitation).', + showToolCallInProgress: + 'Automatically expand tool call details while executing and keep them expanded after completion.', pyInterpreterEnabled: 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.', enableContinueGeneration: diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts index 26c0d9aafe..ff39dd6a07 100644 --- a/tools/server/webui/src/lib/stores/agentic.svelte.ts +++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts @@ -31,7 +31,7 @@ import { import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api'; import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat'; import type { MCPToolCall } from '$lib/types/mcp'; -import type { DatabaseMessage, DatabaseMessageExtra } from '$lib/types/database'; +import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '$lib/types/database'; import { getAgenticConfig } from '$lib/config/agentic'; import { config } from '$lib/stores/settings.svelte'; import { getAuthHeaders } from '$lib/utils'; @@ -69,6 +69,8 @@ export interface AgenticFlowParams { options?: AgenticFlowOptions; callbacks: AgenticFlowCallbacks; signal?: AbortSignal; + /** Per-chat MCP server overrides */ + perChatOverrides?: McpServerOverride[]; } export interface AgenticFlowResult { @@ -126,18 +128,18 @@ class AgenticStore { * @returns AgenticFlowResult indicating if the flow handled the request */ async runAgenticFlow(params: AgenticFlowParams): Promise { - const { messages, options = {}, callbacks, signal } = params; + const { messages, options = {}, callbacks, signal, perChatOverrides } = params; const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } = callbacks; - // Get agentic configuration - const agenticConfig = getAgenticConfig(config()); + // Get agentic configuration (considering per-chat MCP overrides) + const agenticConfig = getAgenticConfig(config(), perChatOverrides); if (!agenticConfig.enabled) { return { handled: false }; } - // Ensure MCP is initialized - const hostManager = await mcpStore.ensureInitialized(); + // Ensure MCP is initialized with per-chat overrides + const hostManager = await mcpStore.ensureInitialized(perChatOverrides); if (!hostManager) { console.log('[AgenticStore] MCP not initialized, falling back to standard chat'); return { handled: false }; diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index ffcc0a13e6..3005063b4b 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -634,8 +634,9 @@ class ChatStore { } }; - // Try agentic flow first if enabled - const agenticConfig = getAgenticConfig(config()); + // Try agentic flow first if enabled (considering per-chat MCP overrides) + const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides; + const agenticConfig = getAgenticConfig(config(), perChatOverrides); if (agenticConfig.enabled) { const agenticResult = await agenticStore.runAgenticFlow({ messages: allMessages, @@ -644,7 +645,8 @@ class ChatStore { ...(modelOverride ? { model: modelOverride } : {}) }, callbacks: streamCallbacks, - signal: abortController.signal + signal: abortController.signal, + perChatOverrides }); if (agenticResult.handled) { diff --git a/tools/server/webui/src/lib/stores/conversations.svelte.ts b/tools/server/webui/src/lib/stores/conversations.svelte.ts index 3300eb3113..4538ac108d 100644 --- a/tools/server/webui/src/lib/stores/conversations.svelte.ts +++ b/tools/server/webui/src/lib/stores/conversations.svelte.ts @@ -5,6 +5,7 @@ import { DatabaseService } from '$lib/services/database'; import { config } from '$lib/stores/settings.svelte'; import { filterByLeafNodeId, findLeafNode } from '$lib/utils'; import { AttachmentType } from '$lib/enums'; +import type { McpServerOverride } from '$lib/types/database'; /** * conversationsStore - Persistent conversation data and lifecycle management @@ -62,6 +63,9 @@ class ConversationsStore { /** Whether the store has been initialized */ isInitialized = $state(false); + /** Pending MCP server overrides for new conversations (before first message) */ + pendingMcpServerOverrides = $state([]); + /** Callback for title update confirmation dialog */ titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise; @@ -170,6 +174,20 @@ class ConversationsStore { const conversationName = name || `Chat ${new Date().toLocaleString()}`; const conversation = await DatabaseService.createConversation(conversationName); + // Apply any pending MCP server overrides to the new conversation + if (this.pendingMcpServerOverrides.length > 0) { + // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB) + const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({ + serverId: o.serverId, + enabled: o.enabled + })); + conversation.mcpServerOverrides = plainOverrides; + await DatabaseService.updateConversation(conversation.id, { + mcpServerOverrides: plainOverrides + }); + this.pendingMcpServerOverrides = []; // Clear pending overrides + } + this.conversations.unshift(conversation); this.activeConversation = conversation; this.activeMessages = []; @@ -192,6 +210,8 @@ class ConversationsStore { return false; } + // Clear pending overrides when switching to an existing conversation + this.pendingMcpServerOverrides = []; this.activeConversation = conversation; if (conversation.currNode) { @@ -333,6 +353,150 @@ class ConversationsStore { } } + // ───────────────────────────────────────────────────────────────────────────── + // MCP Server Per-Chat Overrides + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Gets MCP server override for a specific server in the active conversation. + * Falls back to pending overrides if no active conversation exists. + * @param serverId - The server ID to check + * @returns The override if set, undefined if using global setting + */ + getMcpServerOverride(serverId: string): McpServerOverride | undefined { + if (this.activeConversation) { + return this.activeConversation.mcpServerOverrides?.find( + (o: McpServerOverride) => o.serverId === serverId + ); + } + // Fall back to pending overrides if no active conversation + return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId); + } + + /** + * Checks if an MCP server is enabled for the active conversation. + * Per-chat override takes precedence over global setting. + * @param serverId - The server ID to check + * @param globalEnabled - The global enabled state from settings + * @returns True if server is enabled for this conversation + */ + isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean { + const override = this.getMcpServerOverride(serverId); + return override !== undefined ? override.enabled : globalEnabled; + } + + /** + * Sets or removes MCP server override for the active conversation. + * If no conversation exists, stores as pending override (applied when conversation is created). + * @param serverId - The server ID to override + * @param enabled - The enabled state, or undefined to remove override (use global) + */ + async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise { + // If no active conversation, store as pending override + if (!this.activeConversation) { + this.setPendingMcpServerOverride(serverId, enabled); + return; + } + + // Clone to plain objects to avoid Proxy serialization issues with IndexedDB + const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map( + (o: McpServerOverride) => ({ + serverId: o.serverId, + enabled: o.enabled + }) + ); + let newOverrides: McpServerOverride[]; + + if (enabled === undefined) { + // Remove override - use global setting + newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId); + } else { + // Set or update override + const existingIndex = currentOverrides.findIndex( + (o: McpServerOverride) => o.serverId === serverId + ); + if (existingIndex >= 0) { + newOverrides = [...currentOverrides]; + newOverrides[existingIndex] = { serverId, enabled }; + } else { + newOverrides = [...currentOverrides, { serverId, enabled }]; + } + } + + // Update in database (plain objects, not proxies) + await DatabaseService.updateConversation(this.activeConversation.id, { + mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined + }); + + // Update local state + this.activeConversation.mcpServerOverrides = newOverrides.length > 0 ? newOverrides : undefined; + + // Also update in conversations list + const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); + if (convIndex !== -1) { + this.conversations[convIndex].mcpServerOverrides = + newOverrides.length > 0 ? newOverrides : undefined; + } + } + + /** + * Toggles MCP server enabled state for the active conversation. + * Creates a per-chat override that differs from the global setting. + * @param serverId - The server ID to toggle + * @param globalEnabled - The global enabled state from settings + */ + async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise { + const currentEnabled = this.isMcpServerEnabledForChat(serverId, globalEnabled); + await this.setMcpServerOverride(serverId, !currentEnabled); + } + + /** + * Resets MCP server to use global setting (removes per-chat override). + * @param serverId - The server ID to reset + */ + async resetMcpServerToGlobal(serverId: string): Promise { + await this.setMcpServerOverride(serverId, undefined); + } + + /** + * Sets or removes a pending MCP server override (for new conversations). + * @param serverId - The server ID to override + * @param enabled - The enabled state, or undefined to remove override + */ + private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void { + if (enabled === undefined) { + // Remove pending override + this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter( + (o) => o.serverId !== serverId + ); + } else { + // Set or update pending override + const existingIndex = this.pendingMcpServerOverrides.findIndex( + (o) => o.serverId === serverId + ); + if (existingIndex >= 0) { + this.pendingMcpServerOverrides[existingIndex] = { serverId, enabled }; + } else { + this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }]; + } + } + } + + /** + * Gets a pending MCP server override. + * @param serverId - The server ID to check + */ + private getPendingMcpServerOverride(serverId: string): McpServerOverride | undefined { + return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId); + } + + /** + * Clears all pending MCP server overrides. + */ + clearPendingMcpServerOverrides(): void { + this.pendingMcpServerOverrides = []; + } + /** * Navigates to a specific sibling branch by updating currNode and refreshing messages * @param siblingId - The sibling message ID to navigate to diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts index a704403d56..c1ac4cf98b 100644 --- a/tools/server/webui/src/lib/stores/mcp.svelte.ts +++ b/tools/server/webui/src/lib/stores/mcp.svelte.ts @@ -5,9 +5,10 @@ import { type ServerStatus } from '$lib/mcp/host-manager'; import type { ToolExecutionResult } from '$lib/mcp/server-connection'; -import { buildMcpClientConfig } from '$lib/config/mcp'; -import { config } from '$lib/stores/settings.svelte'; +import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/config/mcp'; +import { config, settingsStore } from '$lib/stores/settings.svelte'; import type { MCPToolCall } from '$lib/types/mcp'; +import type { McpServerOverride } from '$lib/types/database'; import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; import { MCPClient } from '$lib/mcp'; import { detectMcpTransportFromUrl } from '$lib/utils/mcp'; @@ -139,11 +140,14 @@ class MCPStore { * Ensure MCP host manager is initialized with current config. * Returns the host manager if successful, undefined otherwise. * Handles config changes by reinitializing as needed. + * @param perChatOverrides - Optional per-chat MCP server overrides */ - async ensureInitialized(): Promise { + async ensureInitialized( + perChatOverrides?: McpServerOverride[] + ): Promise { if (!browser) return undefined; - const mcpConfig = buildMcpClientConfig(config()); + const mcpConfig = buildMcpClientConfig(config(), perChatOverrides); const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; // No config - shutdown if needed diff --git a/tools/server/webui/src/lib/types/database.d.ts b/tools/server/webui/src/lib/types/database.d.ts index 1a336e059c..3f18755e76 100644 --- a/tools/server/webui/src/lib/types/database.d.ts +++ b/tools/server/webui/src/lib/types/database.d.ts @@ -1,11 +1,22 @@ import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat'; import { AttachmentType } from '$lib/enums'; +/** + * Per-chat MCP server override - allows enabling/disabling servers for specific conversations. + * If undefined for a server, the global setting is used. + */ +export interface McpServerOverride { + serverId: string; + enabled: boolean; +} + export interface DatabaseConversation { currNode: string | null; id: string; lastModified: number; name: string; + /** Per-chat MCP server overrides. If not set, global settings are used. */ + mcpServerOverrides?: McpServerOverride[]; } export interface DatabaseMessageExtraAudioFile {