feat: Add per-chat MCP server overrides

This commit is contained in:
Aleksander Grygier 2026-01-03 01:11:55 +01:00
parent 54374edecd
commit dfce09b34b
8 changed files with 286 additions and 19 deletions

View File

@ -1,14 +1,20 @@
import { hasEnabledMcpServers } from './mcp'; import { hasEnabledMcpServers } from './mcp';
import type { SettingsConfigType } from '$lib/types/settings'; import type { SettingsConfigType } from '$lib/types/settings';
import type { AgenticConfig } from '$lib/types/agentic'; import type { AgenticConfig } from '$lib/types/agentic';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic'; import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
import { normalizePositiveNumber } from '$lib/utils/number'; import { normalizePositiveNumber } from '$lib/utils/number';
/** /**
* Gets the current agentic configuration. * Gets the current agentic configuration.
* Automatically disables agentic mode if no MCP servers are configured. * 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( const maxTurns = normalizePositiveNumber(
settings.agenticMaxTurns, settings.agenticMaxTurns,
DEFAULT_AGENTIC_CONFIG.maxTurns DEFAULT_AGENTIC_CONFIG.maxTurns
@ -23,7 +29,7 @@ export function getAgenticConfig(settings: SettingsConfigType): AgenticConfig {
: DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn; : DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn;
return { return {
enabled: hasEnabledMcpServers(settings) && DEFAULT_AGENTIC_CONFIG.enabled, enabled: hasEnabledMcpServers(settings, perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
maxTurns, maxTurns,
maxToolPreviewLines, maxToolPreviewLines,
filterReasoningAfterFirstTurn filterReasoningAfterFirstTurn

View File

@ -1,5 +1,6 @@
import type { MCPClientConfig, MCPServerConfig, MCPServerSettingsEntry } from '$lib/types/mcp'; import type { MCPClientConfig, MCPServerConfig, MCPServerSettingsEntry } from '$lib/types/mcp';
import type { SettingsConfigType } from '$lib/types/settings'; import type { SettingsConfigType } from '$lib/types/settings';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { detectMcpTransportFromUrl, generateMcpServerId } from '$lib/utils/mcp'; import { detectMcpTransportFromUrl, generateMcpServerId } from '$lib/utils/mcp';
import { normalizePositiveNumber } from '$lib/utils/number'; 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. * Builds MCP client configuration from settings.
* Returns undefined if no valid servers are configured. * 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); const rawServers = parseMcpServerSettings(config.mcpServers);
if (!rawServers.length) { if (!rawServers.length) {
@ -89,7 +112,7 @@ export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfi
const servers: Record<string, MCPServerConfig> = {}; const servers: Record<string, MCPServerConfig> = {};
for (const [index, entry] of rawServers.entries()) { for (const [index, entry] of rawServers.entries()) {
if (!entry.enabled) continue; if (!isServerEnabled(entry, perChatOverrides)) continue;
const normalized = buildServerConfig(entry); const normalized = buildServerConfig(entry);
if (normalized) { if (normalized) {
@ -110,6 +133,55 @@ export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfi
}; };
} }
export function hasEnabledMcpServers(config: SettingsConfigType): boolean { export function hasEnabledMcpServers(
return Boolean(buildMcpClientConfig(config)); config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): boolean {
return Boolean(buildMcpClientConfig(config, perChatOverrides));
}
// ─────────────────────────────────────────────────────────────────────────────
// MCP Server Usage Stats
// ─────────────────────────────────────────────────────────────────────────────
export type McpServerUsageStats = Record<string, number>;
/**
* 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);
} }

View File

@ -19,9 +19,11 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
autoShowSidebarOnNewChat: true, autoShowSidebarOnNewChat: true,
autoMicOnEmpty: false, autoMicOnEmpty: false,
mcpServers: '[]', mcpServers: '[]',
mcpServerUsageStats: '{}', // JSON object: { [serverId]: usageCount }
agenticMaxTurns: 10, agenticMaxTurns: 10,
agenticMaxToolPreviewLines: 25, agenticMaxToolPreviewLines: 25,
agenticFilterReasoningAfterFirstTurn: true, agenticFilterReasoningAfterFirstTurn: true,
showToolCallInProgress: false,
// make sure these default values are in sync with `common.h` // make sure these default values are in sync with `common.h`
samplers: 'top_k;typ_p;top_p;min_p;temperature', samplers: 'top_k;typ_p;top_p;min_p;temperature',
backend_sampling: false, backend_sampling: false,
@ -113,12 +115,16 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.', 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
mcpServers: mcpServers:
'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.', '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: agenticMaxTurns:
'Maximum number of tool execution cycles before stopping (prevents infinite loops).', 'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
agenticMaxToolPreviewLines: 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.', '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: agenticFilterReasoningAfterFirstTurn:
'Only show reasoning from the first agentic turn. When disabled, reasoning from all turns is merged in one (WebUI limitation).', '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: pyInterpreterEnabled:
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.', 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
enableContinueGeneration: enableContinueGeneration:

View File

@ -31,7 +31,7 @@ import {
import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api'; import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api';
import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat'; import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
import type { MCPToolCall } from '$lib/types/mcp'; 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 { getAgenticConfig } from '$lib/config/agentic';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { getAuthHeaders } from '$lib/utils'; import { getAuthHeaders } from '$lib/utils';
@ -69,6 +69,8 @@ export interface AgenticFlowParams {
options?: AgenticFlowOptions; options?: AgenticFlowOptions;
callbacks: AgenticFlowCallbacks; callbacks: AgenticFlowCallbacks;
signal?: AbortSignal; signal?: AbortSignal;
/** Per-chat MCP server overrides */
perChatOverrides?: McpServerOverride[];
} }
export interface AgenticFlowResult { export interface AgenticFlowResult {
@ -126,18 +128,18 @@ class AgenticStore {
* @returns AgenticFlowResult indicating if the flow handled the request * @returns AgenticFlowResult indicating if the flow handled the request
*/ */
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> { async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
const { messages, options = {}, callbacks, signal } = params; const { messages, options = {}, callbacks, signal, perChatOverrides } = params;
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } = const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } =
callbacks; callbacks;
// Get agentic configuration // Get agentic configuration (considering per-chat MCP overrides)
const agenticConfig = getAgenticConfig(config()); const agenticConfig = getAgenticConfig(config(), perChatOverrides);
if (!agenticConfig.enabled) { if (!agenticConfig.enabled) {
return { handled: false }; return { handled: false };
} }
// Ensure MCP is initialized // Ensure MCP is initialized with per-chat overrides
const hostManager = await mcpStore.ensureInitialized(); const hostManager = await mcpStore.ensureInitialized(perChatOverrides);
if (!hostManager) { if (!hostManager) {
console.log('[AgenticStore] MCP not initialized, falling back to standard chat'); console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
return { handled: false }; return { handled: false };

View File

@ -808,8 +808,9 @@ class ChatStore {
} }
}; };
// Try agentic flow first if enabled // Try agentic flow first if enabled (considering per-chat MCP overrides)
const agenticConfig = getAgenticConfig(config()); const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
const agenticConfig = getAgenticConfig(config(), perChatOverrides);
if (agenticConfig.enabled) { if (agenticConfig.enabled) {
const agenticResult = await agenticStore.runAgenticFlow({ const agenticResult = await agenticStore.runAgenticFlow({
messages: allMessages, messages: allMessages,
@ -818,7 +819,8 @@ class ChatStore {
...(modelOverride ? { model: modelOverride } : {}) ...(modelOverride ? { model: modelOverride } : {})
}, },
callbacks: streamCallbacks, callbacks: streamCallbacks,
signal: abortController.signal signal: abortController.signal,
perChatOverrides
}); });
if (agenticResult.handled) { if (agenticResult.handled) {

View File

@ -5,6 +5,7 @@ import { DatabaseService } from '$lib/services/database';
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 { AttachmentType } from '$lib/enums'; import { AttachmentType } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
/** /**
* conversationsStore - Persistent conversation data and lifecycle management * conversationsStore - Persistent conversation data and lifecycle management
@ -62,6 +63,9 @@ class ConversationsStore {
/** Whether the store has been initialized */ /** Whether the store has been initialized */
isInitialized = $state(false); isInitialized = $state(false);
/** Pending MCP server overrides for new conversations (before first message) */
pendingMcpServerOverrides = $state<McpServerOverride[]>([]);
/** Callback for title update confirmation dialog */ /** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>; titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
@ -170,6 +174,20 @@ class ConversationsStore {
const conversationName = name || `Chat ${new Date().toLocaleString()}`; const conversationName = name || `Chat ${new Date().toLocaleString()}`;
const conversation = await DatabaseService.createConversation(conversationName); 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.conversations.unshift(conversation);
this.activeConversation = conversation; this.activeConversation = conversation;
this.activeMessages = []; this.activeMessages = [];
@ -192,6 +210,8 @@ class ConversationsStore {
return false; return false;
} }
// Clear pending overrides when switching to an existing conversation
this.pendingMcpServerOverrides = [];
this.activeConversation = conversation; this.activeConversation = conversation;
if (conversation.currNode) { 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<void> {
// 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<void> {
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<void> {
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 * Navigates to a specific sibling branch by updating currNode and refreshing messages
* @param siblingId - The sibling message ID to navigate to * @param siblingId - The sibling message ID to navigate to

View File

@ -5,9 +5,10 @@ import {
type ServerStatus type ServerStatus
} from '$lib/mcp/host-manager'; } from '$lib/mcp/host-manager';
import type { ToolExecutionResult } from '$lib/mcp/server-connection'; import type { ToolExecutionResult } from '$lib/mcp/server-connection';
import { buildMcpClientConfig } from '$lib/config/mcp'; import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/config/mcp';
import { config } from '$lib/stores/settings.svelte'; import { config, settingsStore } from '$lib/stores/settings.svelte';
import type { MCPToolCall } from '$lib/types/mcp'; import type { MCPToolCall } from '$lib/types/mcp';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { MCPClient } from '$lib/mcp'; import { MCPClient } from '$lib/mcp';
import { detectMcpTransportFromUrl } from '$lib/utils/mcp'; import { detectMcpTransportFromUrl } from '$lib/utils/mcp';
@ -139,11 +140,14 @@ class MCPStore {
* Ensure MCP host manager is initialized with current config. * Ensure MCP host manager is initialized with current config.
* Returns the host manager if successful, undefined otherwise. * Returns the host manager if successful, undefined otherwise.
* Handles config changes by reinitializing as needed. * Handles config changes by reinitializing as needed.
* @param perChatOverrides - Optional per-chat MCP server overrides
*/ */
async ensureInitialized(): Promise<MCPHostManager | undefined> { async ensureInitialized(
perChatOverrides?: McpServerOverride[]
): Promise<MCPHostManager | undefined> {
if (!browser) return undefined; if (!browser) return undefined;
const mcpConfig = buildMcpClientConfig(config()); const mcpConfig = buildMcpClientConfig(config(), perChatOverrides);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
// No config - shutdown if needed // No config - shutdown if needed

View File

@ -1,11 +1,22 @@
import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat'; import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
import { AttachmentType } from '$lib/enums'; 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 { export interface DatabaseConversation {
currNode: string | null; currNode: string | null;
id: string; id: string;
lastModified: number; lastModified: number;
name: string; name: string;
/** Per-chat MCP server overrides. If not set, global settings are used. */
mcpServerOverrides?: McpServerOverride[];
} }
export interface DatabaseMessageExtraAudioFile { export interface DatabaseMessageExtraAudioFile {