feat: Add per-chat MCP server overrides

This commit is contained in:
Aleksander Grygier 2026-01-03 01:11:55 +01:00
parent 865c28a96d
commit 81ad2d5569
8 changed files with 286 additions and 19 deletions

View File

@ -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

View File

@ -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<string, MCPServerConfig> = {};
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<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,
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<string, string> = {
'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:

View File

@ -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<AgenticFlowResult> {
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 };

View File

@ -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) {

View File

@ -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<McpServerOverride[]>([]);
/** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
@ -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<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
* @param siblingId - The sibling message ID to navigate to

View File

@ -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<MCPHostManager | undefined> {
async ensureInitialized(
perChatOverrides?: McpServerOverride[]
): Promise<MCPHostManager | undefined> {
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

View File

@ -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 {