diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 40819d6f1a..006ec39ce9 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -152,9 +152,8 @@ getServerHome: () => toolsStore.serverHome ?? null, getShowModelSelector: () => showModelSelector, getValue: () => value, - hasCwdTools: () => toolsStore.hasEnabledCwdTools, - hasPrompts: () => - mcpStore.hasPromptsCapability(conversationsStore.preferences.getAllMcpServerOverrides()), + hasCwdTools: () => conversationsStore.preferences.hasEnabledCwdTools(), + hasPrompts: () => mcpStore.hasPromptsCapability(), openModelSelector: () => chatFormActionsRef?.openModelSelector(), setCaretOffset: (offset) => inputRef?.setCaretOffset(offset), setValue: (v) => { @@ -635,7 +634,7 @@ - {#if toolsStore.hasEnabledCwdTools} + {#if conversationsStore.preferences.hasEnabledCwdTools()} - import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte'; - import { - Check, - ChevronDown, - ChevronRight, - Lightbulb, - LightbulbOff, - PencilRuler - } from '@lucide/svelte'; + import { File, MessageSquare } from '@lucide/svelte'; + import { ChevronDown, ChevronRight, PencilRuler } from '@lucide/svelte'; import { McpLogo } from '$lib/components/app'; import { Checkbox } from '$lib/components/ui/checkbox'; import * as Collapsible from '$lib/components/ui/collapsible'; import * as Sheet from '$lib/components/ui/sheet'; - import { Switch } from '$lib/components/ui/switch'; import * as Tooltip from '$lib/components/ui/tooltip'; import { ATTACHMENT_FILE_ITEMS, @@ -20,12 +12,10 @@ TOOLTIP_DELAY_DURATION } from '$lib/constants'; import { getChatFormActionsContext } from '$lib/contexts'; - import { HealthCheckStatus } from '$lib/enums'; import { AttachmentAction } from '$lib/enums/attachment.enums'; import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte'; - import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte'; import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte'; - import { conversationsStore, mcpStore } from '$lib/stores'; + import type { ToolGroup } from '$lib/types'; import type { Snippet } from 'svelte'; interface Props { @@ -38,10 +28,8 @@ const chatFormActions = getChatFormActionsContext(); let sheetOpen = $state(false); - let reasoningExpanded = $state(false); let filesExpanded = $state(true); let toolsExpanded = $state(false); - let mcpExpanded = $state(false); const attachmentMenu = useAttachmentMenu( () => ({ @@ -63,15 +51,12 @@ ); const toolsPanel = useToolsPanel(); - const reasoning = useReasoningMenu(); const sheetItemClass = 'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50'; const sheetItemRowClass = 'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent'; - - let mcpServers = $derived(mcpStore.getServers());
@@ -88,65 +73,6 @@
- {#if reasoning.modelSupportsThinking} - (reasoningExpanded = open)} - open={reasoningExpanded} - > - - {#if reasoningExpanded} - - {:else} - - {/if} - - {#if reasoning.thinkingEnabled} - - {:else if reasoning.isOff} - - {:else} - - {/if} - - Reasoning - - - {reasoning.currentEffort} - - - - -
- {#each reasoning.levels as level (level.value)} - {@const tokenLabel = reasoning.tokenLabel(level)} - - {/each} -
-
-
- {/if} - (filesExpanded = open)} open={filesExpanded}> {#if filesExpanded} @@ -194,80 +120,15 @@ - (mcpExpanded = open)} open={mcpExpanded}> - - {#if mcpExpanded} - - {:else} - - {/if} + - {/each} - - {#if mcpServers.length === 0} -
- No MCP servers configured -
- {/if} -
- - + System Message + {#if toolsPanel.totalToolCount > 0} (toolsExpanded = open)} open={toolsExpanded}> @@ -289,40 +150,12 @@
- {#each toolsPanel.activeGroups as group (group.key)} - {@const checked = toolsPanel.isGroupChecked(group)} - {@const enabledCount = toolsPanel.getEnabledToolCount(group)} - {@const favicon = toolsPanel.getFavicon(group)} + {#each toolsPanel.categoryGroups as group (group.key)} + {@render sheetGroupRow(group)} + {/each} - + {#each toolsPanel.mcpGroups as group (group.key)} + {@render sheetGroupRow(group)} {/each}
@@ -331,38 +164,54 @@ - - {#if chatFormActions.hasMcpPromptsSupport} - - {/if} - - {#if chatFormActions.hasMcpResourcesSupport} - - {/if}
+ +{#snippet sheetGroupRow(group: ToolGroup)} + {@const checked = toolsPanel.isGroupChecked(group)} + {@const enabledCount = toolsPanel.getEnabledToolCount(group)} + {@const favicon = toolsPanel.getFavicon(group)} + {@const groupDisabled = toolsPanel.isGroupDisabled(group)} + + +{/snippet} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte index 40fed27c70..79f533b22f 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte @@ -7,6 +7,7 @@ import { CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants'; import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte'; import { mcpStore, toolsStore } from '$lib/stores'; + import type { ToolGroup } from '$lib/types'; const toolsPanel = useToolsPanel(); const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0); @@ -62,95 +63,107 @@ {/if} {:else}
- {#each toolsPanel.activeGroups as group (group.key)} - {@const isExpanded = toolsPanel.expandedGroups.has(group.key)} - {@const checked = toolsPanel.isGroupChecked(group)} - {@const favicon = toolsPanel.getFavicon(group)} + {#each toolsPanel.categoryGroups as group (group.key)} + {@render groupRow(group)} + {/each} - toolsPanel.toggleGroupExpanded(group.key)} - open={isExpanded} - > -
- - {#if isExpanded} - - {:else} - - {/if} - - - {#if favicon} - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - src={favicon} - /> - {/if} - - {group.label} - - - - {toolsPanel.getEnabledToolCount(group)}/{group.tools.length} - - - - - - {#snippet child({ props })} - toolsPanel.toggleGroupByKey(group.key)} - /> - {/snippet} - - - -

- {checked ? 'Disable' : 'Enable'} - {group.tools.length} tool{group.tools.length !== 1 ? 's' : ''} -

-
-
-
- - -
- {#each group.tools as entry (entry.key)} - {@const enabled = toolsStore.isToolEnabled(entry.key)} - - {/each} -
-
-
+ {#each toolsPanel.mcpGroups as group (group.key)} + {@render groupRow(group)} {/each}
{/if} + +{#snippet groupRow(group: ToolGroup)} + {@const isExpanded = toolsPanel.expandedGroups.has(group.key)} + {@const checked = toolsPanel.isGroupChecked(group)} + {@const favicon = toolsPanel.getFavicon(group)} + {@const groupDisabled = toolsPanel.isGroupDisabled(group)} + + toolsPanel.toggleGroupExpanded(group.key)} + open={isExpanded} + > +
+ + {#if isExpanded} + + {:else} + + {/if} + + + {#if favicon} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + src={favicon} + /> + {/if} + + {group.label} + + + + {toolsPanel.getEnabledToolCount(group)}/{group.tools.length} + + + + + + {#snippet child({ props })} + toolsPanel.toggleGroupByKey(group.key)} + /> + {/snippet} + + + +

+ {checked ? 'Disable' : 'Enable'} + {group.tools.length} tool{group.tools.length !== 1 ? 's' : ''} +

+
+
+
+ + +
+ {#each group.tools as entry (entry.key)} + {@const enabled = toolsPanel.isToolEnabled(entry)} + {@const parentDisabled = toolsPanel.isToolParentDisabled(entry)} + + {/each} +
+
+
+{/snippet} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte index f1aa743693..2fcb1ed5f8 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte @@ -58,17 +58,9 @@ let currentConfig = $derived(settingsStore.config); - let hasMcpPromptsSupport = $derived.by(() => { - const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides(); + let hasMcpPromptsSupport = $derived.by(() => mcpStore.hasPromptsCapability()); - return mcpStore.hasPromptsCapability(perChatOverrides); - }); - - let hasMcpResourcesSupport = $derived.by(() => { - const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides(); - - return mcpStore.hasResourcesCapability(perChatOverrides); - }); + let hasMcpResourcesSupport = $derived.by(() => mcpStore.hasResourcesCapability()); let hasAudioModality = $state(false); let hasVideoModality = $state(false); diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte index 353d6e7baf..f6a3ee1347 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte @@ -9,7 +9,7 @@ } from '$lib/components/app/chat'; import Badge from '$lib/components/ui/badge/badge.svelte'; import { KeyboardKey } from '$lib/enums'; - import { conversationsStore, mcpStore } from '$lib/stores'; + import { mcpStore } from '$lib/stores'; import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types'; import { debounce, uuid } from '$lib/utils'; import { SvelteMap } from 'svelte/reactivity'; @@ -87,8 +87,7 @@ isLoading = true; try { - const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides(); - const initialized = await mcpStore.ensureInitialized(perChatOverrides); + const initialized = await mcpStore.ensureInitialized(); if (!initialized) { prompts = []; diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte index 6dfcdb856c..82a07477d1 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte @@ -8,7 +8,7 @@ import { Button } from '$lib/components/ui/button'; import * as Dialog from '$lib/components/ui/dialog'; import { ICON_CLASS_DEFAULT } from '$lib/constants'; - import { conversationsStore, mcpStore } from '$lib/stores'; + import { mcpStore } from '$lib/stores'; import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types'; import { getResourceDisplayName } from '$lib/utils'; import { SvelteSet } from 'svelte/reactivity'; @@ -48,8 +48,7 @@ }); async function loadResources() { - const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides(); - const initialized = await mcpStore.ensureInitialized(perChatOverrides); + const initialized = await mcpStore.ensureInitialized(); if (initialized) { await mcpStore.fetchAllResources(); diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte index fab45aa970..bc28a754c6 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte @@ -10,7 +10,7 @@ RECOMMENDED_MCP_SERVERS } from '$lib/constants'; import { BooleanString, HealthCheckStatus } from '$lib/enums'; - import { conversationsStore, mcpStore } from '$lib/stores'; + import { mcpStore } from '$lib/stores'; import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils'; interface Props { @@ -234,8 +234,6 @@ useProxy: newServerUseProxy }); - conversationsStore.preferences.setMcpServerOverride(newServerId, true); - handleOpenChange(false); } diff --git a/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte b/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte index ea274d5aa7..5ac7a7fae6 100644 --- a/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte @@ -3,7 +3,7 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import { ICON_CLASS_DEFAULT, MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants'; import { HealthCheckStatus } from '$lib/enums'; - import { conversationsStore, mcpStore } from '$lib/stores'; + import { mcpStore } from '$lib/stores'; interface Props { class?: string; @@ -13,11 +13,7 @@ let { class: className = '', onclick }: Props = $props(); let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled)); - let enabledMcpServersForChat = $derived( - mcpServers.filter( - (s) => conversationsStore.preferences.isMcpServerEnabledForChat(s.id) && s.url.trim() - ) - ); + let enabledMcpServersForChat = $derived(mcpServers.filter((s) => s.url.trim())); let healthyEnabledMcpServers = $derived( enabledMcpServersForChat.filter((s) => { const healthState = mcpStore.getHealthCheckState(s.id); diff --git a/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte b/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte index dd4b96c626..127a035ad5 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte @@ -7,7 +7,7 @@ import { Button } from '$lib/components/ui/button'; import * as Empty from '$lib/components/ui/empty'; import { HealthCheckStatus } from '$lib/enums'; - import { conversationsStore, mcpStore, toolsStore } from '$lib/stores'; + import { mcpStore, toolsStore } from '$lib/stores'; import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; @@ -86,15 +86,14 @@ {:else} (isResourcesDialogOpen = true)} onDelete={() => mcpStore.removeServer(server.id)} onToggle={async () => { - const wasEnabled = conversationsStore.preferences.isMcpServerEnabledForChat( - server.id - ); + const wasEnabled = server.enabled; - await conversationsStore.preferences.toggleMcpServerForChat(server.id); + mcpStore.updateServer(server.id, { enabled: !wasEnabled }); if (!wasEnabled) { // Promote the connection so tools/prompts/resources become @@ -104,7 +103,6 @@ } }} onUpdate={(updates) => mcpStore.updateServer(server.id, updates)} - {server} /> {/if} {/each} diff --git a/tools/ui/src/lib/constants/storage.constants.ts b/tools/ui/src/lib/constants/storage.constants.ts index 918ee45086..0aad7c7706 100644 --- a/tools/ui/src/lib/constants/storage.constants.ts +++ b/tools/ui/src/lib/constants/storage.constants.ts @@ -20,6 +20,9 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool /** Disabled tools keyed by stable selection identity, no migration from the name based key */ export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`; + +/** Default disabled tool categories, seeded into newly created conversations */ +export const DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolCategories`; export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`; export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`; export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`; diff --git a/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts index e9dc0dcab6..2c287a4848 100644 --- a/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts +++ b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts @@ -1,22 +1,27 @@ import { CLI_FLAGS } from '$lib/constants'; import { ToolSource } from '$lib/enums'; import { conversationsStore, mcpStore, toolsStore } from '$lib/stores'; -import type { ToolGroup } from '$lib/types'; +import type { ToolEntry, ToolGroup } from '$lib/types'; import { SvelteSet } from 'svelte/reactivity'; export interface UseToolsPanelReturn { readonly expandedGroups: SvelteSet; - readonly groups: ToolGroup[]; - readonly activeGroups: ToolGroup[]; + readonly categoryGroups: ToolGroup[]; + readonly mcpGroups: ToolGroup[]; readonly totalToolCount: number; readonly noToolsInfoMessage: string | null; + readonly mcpCategoryEnabled: boolean; isGroupChecked(group: ToolGroup): boolean; getEnabledToolCount(group: ToolGroup): number; getFavicon(group: ToolGroup): string | null; isGroupDisabled(group: ToolGroup): boolean; + isToolEnabled(entry: ToolEntry): boolean; + isToolParentDisabled(entry: ToolEntry): boolean; + toggleTool(entry: ToolEntry): void; toggleGroupExpanded(key: string): void; /** Toggle all tools in a group by its stable key (avoids stale group object references). */ toggleGroupByKey(key: string): void; + toggleMcpCategory(): void; handleOpen(): void; } @@ -26,19 +31,21 @@ export interface UseToolsPanelReturn { * Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`) * and the mobile sheet (`ChatFormActionAddSheet`) to avoid * duplicating group filtering, checked-state derivation, and favicon logic. + * + * All toggle state routes through `conversationsStore.preferences`: with an + * active conversation it edits that conversation's tool policy, on the + * new-chat screen it edits the global defaults seeded into new conversations. */ export function useToolsPanel(): UseToolsPanelReturn { const expandedGroups = new SvelteSet(); const groups = $derived(toolsStore.toolGroups); - const activeGroups = $derived( - groups.filter( - (g) => - g.source !== ToolSource.MCP || - !g.serverId || - conversationsStore.preferences.isMcpServerEnabledForChat(g.serverId) - ) + // non-MCP groups are 1:1 with tool categories; MCP tools group per server + const categoryGroups = $derived(groups.filter((g) => g.source !== ToolSource.MCP)); + const mcpGroups = $derived(groups.filter((g) => g.source === ToolSource.MCP)); + const totalToolCount = $derived(groups.reduce((n, g) => n + g.tools.length, 0)); + const mcpCategoryEnabled = $derived( + conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP) ); - const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0)); const noToolsInfoMessage = $derived.by(() => { if (toolsStore.loading) return null; @@ -56,11 +63,11 @@ export function useToolsPanel(): UseToolsPanelReturn { }); function isGroupChecked(group: ToolGroup): boolean { - return toolsStore.isGroupFullyEnabled(group); + return conversationsStore.preferences.isGroupChecked(group); } function getEnabledToolCount(group: ToolGroup): number { - return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.key)).length; + return group.tools.filter((tool) => conversationsStore.preferences.isToolActive(tool)).length; } function getFavicon(group: ToolGroup): string | null { @@ -70,13 +77,25 @@ export function useToolsPanel(): UseToolsPanelReturn { } function isGroupDisabled(group: ToolGroup): boolean { + // MCP server groups gray out while the whole MCP category is off return ( group.source === ToolSource.MCP && - !!group.serverId && - !conversationsStore.preferences.isMcpServerEnabledForChat(group.serverId) + !conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP) ); } + function isToolEnabled(entry: ToolEntry): boolean { + return conversationsStore.preferences.isToolEnabled(entry.key); + } + + function isToolParentDisabled(entry: ToolEntry): boolean { + return conversationsStore.preferences.isToolParentDisabled(entry); + } + + function toggleTool(entry: ToolEntry): void { + void conversationsStore.preferences.toggleTool(entry.key); + } + function toggleGroupExpanded(key: string): void { if (expandedGroups.has(key)) { expandedGroups.delete(key); @@ -87,11 +106,15 @@ export function useToolsPanel(): UseToolsPanelReturn { function toggleGroupByKey(key: string): void { // Find current group by key to get up-to-date tool references - const group = activeGroups.find((g) => g.key === key); + const group = groups.find((g) => g.key === key); if (!group) return; - toolsStore.toggleGroup(group); + void conversationsStore.preferences.toggleGroup(group); + } + + function toggleMcpCategory(): void { + void conversationsStore.preferences.toggleCategory(ToolSource.MCP); } function handleOpen(): void { @@ -103,23 +126,30 @@ export function useToolsPanel(): UseToolsPanelReturn { } return { - get activeGroups() { - return activeGroups; + get categoryGroups() { + return categoryGroups; }, expandedGroups, getEnabledToolCount, getFavicon, - get groups() { - return groups; - }, handleOpen, isGroupChecked, isGroupDisabled, + isToolEnabled, + isToolParentDisabled, + get mcpCategoryEnabled() { + return mcpCategoryEnabled; + }, + get mcpGroups() { + return mcpGroups; + }, get noToolsInfoMessage() { return noToolsInfoMessage; }, toggleGroupByKey, toggleGroupExpanded, + toggleMcpCategory, + toggleTool, get totalToolCount() { return totalToolCount; } diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts index 5d321b3ba2..1b3ecd6120 100644 --- a/tools/ui/src/lib/services/migration.service.ts +++ b/tools/ui/src/lib/services/migration.service.ts @@ -11,6 +11,7 @@ import { CONFIG_LOCALSTORAGE_KEY, DB_APP_NAME_DEPRECATED, + DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY, IDXDB_STORES, IDXDB_TABLES, LEGACY_AGENTIC_REGEX, @@ -21,6 +22,7 @@ import { STORAGE_APP_NAME_DEPRECATED } from '$lib/constants'; import { BooleanString, MessageRole } from '$lib/enums'; +import type { McpServerOverride } from '$lib/types/database'; import Dexie from 'dexie'; // Types @@ -737,6 +739,61 @@ const mcpDefaultOverridesMergeMigration: Migration = { ); } }; +const MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID = 'mcp-server-overrides-to-tool-policy-v1'; +const mcpServerOverridesToToolPolicyMigration: Migration = { + description: + 'Seed per-conversation disabled tool keys from the global defaults and legacy per-conversation MCP server overrides (legacy field preserved)', + id: MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID, + + async run(): Promise { + // The global disabled set used to apply to every conversation; it is now + // the defaults seeded into newly created conversations, so existing rows + // are seeded with it to keep their behavior unchanged. + let defaults: string[] = []; + + try { + const raw = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY); + + if (raw) { + const parsed: unknown = JSON.parse(raw); + + if (Array.isArray(parsed)) { + defaults = parsed.filter((k): k is string => typeof k === 'string'); + } + } + } catch { + return; + } + + const db = await getDatabaseService(); + const conversations = await db.getAllConversations(); + + let migratedCount = 0; + + for (const conv of conversations) { + // re-run safety: a row that already has a policy is left alone + if (conv.disabledTools !== undefined) continue; + + // A legacy per-conversation server disable becomes a server-scoped tool + // key (same format as toolsStore.getMcpServerToolsKey). Per-conversation + // enables are dropped: the global server flag governs now. + const serverGroupKeys = (conv.mcpServerOverrides ?? []) + .filter((o: McpServerOverride) => !o.enabled) + .map((o: McpServerOverride) => `mcp:${o.serverId}`); + const disabledTools = [...new Set([...defaults, ...serverGroupKeys])]; + + if (disabledTools.length === 0) continue; + + await db.updateConversation(conv.id, { disabledTools }); + migratedCount++; + } + + if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) + console.log( + `[Migration] MCP server overrides -> tool policy: updated ${migratedCount} conversations` + ); + } +}; const migrations: Migration[] = [ localStorageMigration, idxdbMigration, @@ -746,7 +803,8 @@ const migrations: Migration[] = [ mcpDefaultEnabledMigration, mcpDefaultOverridesMergeMigration, configTypesMigration, - renderKeysMigration + renderKeysMigration, + mcpServerOverridesToToolPolicyMigration ]; export const MigrationService = { diff --git a/tools/ui/src/lib/stores/agentic/index.svelte.ts b/tools/ui/src/lib/stores/agentic/index.svelte.ts index a91e0ba46f..4d94519db7 100644 --- a/tools/ui/src/lib/stores/agentic/index.svelte.ts +++ b/tools/ui/src/lib/stores/agentic/index.svelte.ts @@ -44,7 +44,6 @@ import type { AgenticFlowParams, AgenticFlowResult, AgenticSession, - McpServerOverride, MCPToolCall, SettingsConfigType, ToolExecutionResult @@ -201,10 +200,10 @@ class AgenticStore { return active; } - getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig { + getConfig(settings: SettingsConfigType): AgenticConfig { const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns; const hasTools = - mcpStore.hasEnabledServers(perChatOverrides) || + mcpStore.hasEnabledServers() || toolsStore.serverTools.length > 0 || toolsStore.browserTools.length > 0 || toolsStore.customTools.length > 0; @@ -309,8 +308,8 @@ class AgenticStore { flowRootMessageId, messages, options = {}, - perChatOverrides, - signal + signal, + toolPolicy } = params; // Clear any pending permissions/continue requests for this conversation when starting a new flow @@ -321,21 +320,24 @@ class AgenticStore { await toolsStore.fetchServerTools(); } - const agenticConfig = this.getConfig(settingsStore.config, perChatOverrides); + const agenticConfig = this.getConfig(settingsStore.config); if (!agenticConfig.enabled) return { handled: false }; - const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides); + const hasMcpServers = mcpStore.hasEnabledServers(); if (hasMcpServers) { - const initialized = await mcpStore.ensureInitialized(perChatOverrides); + const initialized = await mcpStore.ensureInitialized(); if (!initialized) { console.log('[AgenticStore] MCP not initialized'); } } - const tools = toolsStore.getEnabledToolsForLLM(); + const tools = toolsStore.getEnabledToolsForLLM( + new Set(toolPolicy?.disabledTools ?? []), + new Set(toolPolicy?.disabledToolCategories ?? []) + ); if (tools.length === 0) { return { handled: false }; diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts index aab824fd71..296c2cca58 100644 --- a/tools/ui/src/lib/stores/chat/index.svelte.ts +++ b/tools/ui/src/lib/stores/chat/index.svelte.ts @@ -1132,7 +1132,10 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost { await DatabaseService.updateMessage(messageId, updates); } }; - const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides(); + const toolPolicy = { + disabledToolCategories: conversationsStore.preferences.getDisabledToolCategories(), + disabledTools: conversationsStore.preferences.getDisabledTools() + }; { const agenticResult = await agenticStore.runAgenticFlow({ @@ -1144,8 +1147,8 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) }, - perChatOverrides, - signal: abortController.signal + signal: abortController.signal, + toolPolicy }); if (agenticResult.handled) { diff --git a/tools/ui/src/lib/stores/conversations/index.svelte.ts b/tools/ui/src/lib/stores/conversations/index.svelte.ts index 98bf6a0310..f2082ebefc 100644 --- a/tools/ui/src/lib/stores/conversations/index.svelte.ts +++ b/tools/ui/src/lib/stores/conversations/index.svelte.ts @@ -251,12 +251,15 @@ class ConversationsStore implements ConversationsPreferencesHost { */ async createConversation(name?: string): Promise { const conversationName = name || `Chat ${new Date().toLocaleString()}`; - // Working directory and reasoning effort picked on the new-chat screen - // get threaded into the new conversation here, then cleared so they - // don't bleed onto subsequent new chats. + // The tool policy is seeded from the current defaults: edits made inside + // the conversation afterwards live on its row and do not flow back into + // the defaults. Working directory picked on the new-chat screen gets + // threaded in here too, then cleared so it doesn't bleed onto subsequent + // new chats. const conversation = await DatabaseService.createConversation(conversationName, { cwd: this.preferences.pendingCwd ?? undefined, - reasoningEffort: this.preferences.pendingReasoningEffort + reasoningEffort: this.preferences.pendingReasoningEffort, + ...this.preferences.getToolPolicySnapshot() }); this.preferences.pendingCwd = null; diff --git a/tools/ui/src/lib/stores/conversations/preferences.svelte.ts b/tools/ui/src/lib/stores/conversations/preferences.svelte.ts index fea9286033..8106a0d85a 100644 --- a/tools/ui/src/lib/stores/conversations/preferences.svelte.ts +++ b/tools/ui/src/lib/stores/conversations/preferences.svelte.ts @@ -1,21 +1,22 @@ /** * ConversationPreferences - Per-chat options with global fallback * - * Owns the options that resolve per conversation: MCP server overrides, - * reasoning effort, and the working directory. Cwd and reasoning effort are - * buffered as pending state and threaded into the next created conversation - * by the host; MCP server overrides edit the sparse `mcpServerOverrides` - * list on the active row (new-chat toggles edit the server's global flag). + * Owns the options that resolve per conversation: the tool policy (disabled + * categories and tool keys), reasoning effort, and the working directory. + * Tool picks made on the empty new-chat screen edit the global defaults + * directly (they seed every newly created conversation); cwd and reasoning + * effort are buffered as pending state and threaded into the next created + * conversation by the host. * Created and owned by conversationsStore; the host owns the conversation * rows these options persist onto. */ import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants'; -import { ReasoningEffort } from '$lib/enums'; +import { ReasoningEffort, ToolSource } from '$lib/enums'; import { DatabaseService } from '$lib/services/database.service'; // direct imports between stores, not via the barrel, to avoid circular deps -import { mcpStore } from '$lib/stores/mcp/index.svelte'; -import type { McpServerOverride } from '$lib/types/database'; +import { toolsStore } from '$lib/stores/tools.svelte'; +import type { DatabaseConversation, ToolEntry, ToolGroup } from '$lib/types'; /** Load reasoning effort default from localStorage, DEFAULT defers to the server */ function loadReasoningEffortDefault(): ReasoningEffort { @@ -48,6 +49,18 @@ export interface ConversationsPreferencesHost { applyConversationUpdate(id: string, updates: Partial): void; } +/** Effective disabled tool keys: conversation row, falling back to defaults. */ +function buildDisabledTools(conv: DatabaseConversation | null): Set { + return new Set(conv ? (conv.disabledTools ?? []) : [...toolsStore.disabledTools]); +} + +/** Effective disabled tool categories: conversation row, falling back to defaults. */ +function buildDisabledToolCategories(conv: DatabaseConversation | null): Set { + return new Set( + conv ? (conv.disabledToolCategories ?? []) : [...toolsStore.disabledToolCategories] + ); +} + export class ConversationPreferences { /** * Working directory picked on the empty new-chat screen, before any @@ -61,36 +74,35 @@ export class ConversationPreferences { /** Global (non-conversation-specific) reasoning effort default */ pendingReasoningEffort = $state(loadReasoningEffortDefault()); - constructor(private host: ConversationsPreferencesHost) {} + private get _disabledToolCategories(): Set { + return buildDisabledToolCategories(this.host.activeConversation); + } - /** - * Gets the effective override list for the current conversation: - * one entry per configured server, resolved per server. The stored - * per-conversation list is sparse and only holds explicit toggles. - */ - getAllMcpServerOverrides(): McpServerOverride[] { - const overrides = this.host.activeConversation?.mcpServerOverrides; - - return mcpStore.getServers().map((s) => { - const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id); - - return { enabled: override?.enabled ?? s.enabled, serverId: s.id }; - }); + // getters, not $derived fields: lazy evaluation keeps them off the class + // field initialization order (host is assigned by the constructor), and + // reads of the underlying $state stay tracked in reactive contexts + private get _disabledTools(): Set { + return buildDisabledTools(this.host.activeConversation); } /** - * Gets the effective MCP server override for a specific server. - * A per-conversation override wins when present; a server without one - * resolves to its `mcpServers[i].enabled` default. + * + * + * Tool Policy + * + * */ - getMcpServerOverride(serverId: string): McpServerOverride | undefined { - const override = this.host.activeConversation?.mcpServerOverrides?.find( - (o: McpServerOverride) => o.serverId === serverId - ); - if (override) return override; + constructor(private host: ConversationsPreferencesHost) {} - return this.getDefaultOverride(serverId); + /** Effective disabled tool categories for the current context, captured at flow start. */ + getDisabledToolCategories(): ToolSource[] { + return [...this._disabledToolCategories]; + } + + /** Effective disabled tool keys for the current context, captured at flow start. */ + getDisabledTools(): string[] { + return [...this._disabledTools]; } /** @@ -114,16 +126,56 @@ export class ConversationPreferences { return this.pendingReasoningEffort; } - /** Checks if an MCP server is enabled for the active conversation. */ - isMcpServerEnabledForChat(serverId: string): boolean { - const override = this.getMcpServerOverride(serverId); + /** Defaults snapshot for seeding a newly created conversation. */ + getToolPolicySnapshot(): { disabledTools?: string[]; disabledToolCategories?: ToolSource[] } { + const disabledTools = [...toolsStore.disabledTools]; + const disabledToolCategories = [...toolsStore.disabledToolCategories]; - return override?.enabled ?? false; + return { + disabledToolCategories: disabledToolCategories.length ? disabledToolCategories : undefined, + disabledTools: disabledTools.length ? disabledTools : undefined + }; } - /** Removes MCP server override for the active conversation. */ - async removeMcpServerOverride(serverId: string): Promise { - await this.setMcpServerOverride(serverId, undefined); + hasEnabledCwdTools(): boolean { + return toolsStore.hasEnabledCwdTools(this._disabledTools, this._disabledToolCategories); + } + + isCategoryEnabled(source: ToolSource): boolean { + return !this._disabledToolCategories.has(source); + } + + /** Group checkbox state: the category flag, or the server key for MCP groups. */ + isGroupChecked(group: ToolGroup): boolean { + return group.source === ToolSource.MCP && group.serverId + ? this.isServerToolsEnabled(group.serverId) + : this.isCategoryEnabled(group.source); + } + + /** Server-scoped MCP group state: one key disables all of that server's tools. */ + isServerToolsEnabled(serverId: string): boolean { + return this.isToolEnabled(toolsStore.getMcpServerToolsKey(serverId)); + } + + /** Effective state: own key, MCP server group key, and category all on. */ + isToolActive(entry: ToolEntry): boolean { + return toolsStore.isEntryEnabled(entry, this._disabledTools, this._disabledToolCategories); + } + + /** Own-level state: the tool key itself, ignoring category and server group. */ + isToolEnabled(key: string): boolean { + return !this._disabledTools.has(key); + } + + /** True when a parent level (category or MCP server group) disables this entry. */ + isToolParentDisabled(entry: ToolEntry): boolean { + if (!this.isCategoryEnabled(entry.source)) return true; + + return ( + entry.source === ToolSource.MCP && + !!entry.serverId && + !this.isServerToolsEnabled(entry.serverId) + ); } /** Reload persisted defaults, e.g. when the active conversation is cleared. */ @@ -165,57 +217,6 @@ export class ConversationPreferences { this.pendingCwd = null; } - /** - * Sets or removes MCP server override for the active conversation. - * If no conversation exists, persists `enabled` onto `mcpServers[i].enabled` - * (the single source of truth for new-chat defaults). - */ - async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise { - if (!this.host.activeConversation) { - if (enabled !== undefined) { - mcpStore.updateServer(serverId, { enabled }); - } - - return; - } - - // Clone to plain objects to avoid Proxy serialization issues with IndexedDB - const currentOverrides = (this.host.activeConversation.mcpServerOverrides || []).map( - (o: McpServerOverride) => ({ - enabled: o.enabled, - serverId: o.serverId - }) - ); - - let newOverrides: McpServerOverride[]; - - if (enabled === undefined) { - newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId); - } else { - const existingIndex = currentOverrides.findIndex( - (o: McpServerOverride) => o.serverId === serverId - ); - - if (existingIndex >= 0) { - newOverrides = [...currentOverrides]; - newOverrides[existingIndex] = { enabled, serverId }; - } else { - newOverrides = [...currentOverrides, { enabled, serverId }]; - } - } - - const overrides = newOverrides.length > 0 ? newOverrides : undefined; - const id = this.host.activeConversation.id; - - this.host.applyConversationUpdate(id, { - mcpServerOverrides: overrides - }); - - await DatabaseService.updateConversation(id, { - mcpServerOverrides: overrides - }); - } - /** * Sets the reasoning effort for the active conversation. * If no conversation exists, stores the global default. @@ -229,33 +230,98 @@ export class ConversationPreferences { return; } - const id = this.host.activeConversation.id; - - this.host.applyConversationUpdate(id, { + this.host.applyConversationUpdate(this.host.activeConversation.id, { reasoningEffort: effort }); - await DatabaseService.updateConversation(id, { + await DatabaseService.updateConversation(this.host.activeConversation.id, { reasoningEffort: effort }); } - /** Toggles MCP server enabled state for the active conversation. */ - async toggleMcpServerForChat(serverId: string): Promise { - const currentEnabled = this.isMcpServerEnabledForChat(serverId); + async toggleCategory(source: ToolSource): Promise { + const conv: DatabaseConversation | null = this.host.activeConversation; - await this.setMcpServerOverride(serverId, !currentEnabled); + if (!conv) { + toolsStore.toggleCategory(source); + + return; + } + + const next = buildDisabledToolCategories(conv); + + if (next.has(source)) next.delete(source); + else next.add(source); + + await this.persistDisabledToolCategories(next); + } + + async toggleGroup(group: ToolGroup): Promise { + if (group.source === ToolSource.MCP && group.serverId) { + await this.toggleServerTools(group.serverId); + } else { + await this.toggleCategory(group.source); + } + } + + async toggleServerTools(serverId: string): Promise { + await this.toggleTool(toolsStore.getMcpServerToolsKey(serverId)); } /** - * Resolve the default enabled value for a server: its own `enabled` - * flag in `mcpServers`, so the global on/off state lives in one place. + * + * + * Reasoning Effort + * + * */ - private getDefaultOverride(serverId: string): McpServerOverride | undefined { - const server = mcpStore.getServers().find((s) => s.id === serverId); - if (!server) return undefined; + async toggleTool(key: string): Promise { + const conv: DatabaseConversation | null = this.host.activeConversation; - return { enabled: server.enabled, serverId }; + if (!conv) { + toolsStore.toggleTool(key); + + return; + } + + const next = buildDisabledTools(conv); + + if (next.has(key)) next.delete(key); + else next.add(key); + + await this.persistDisabledTools(next); + } + + private async persistDisabledToolCategories(disabled: Set): Promise { + const conv = this.host.activeConversation; + + if (!conv) return; + + const disabledToolCategories = disabled.size ? [...disabled] : undefined; + + this.host.applyConversationUpdate(conv.id, { disabledToolCategories }); + + await DatabaseService.updateConversation(conv.id, { disabledToolCategories }); + } + + /** + * + * + * Working Directory + * + * + */ + + private async persistDisabledTools(disabled: Set): Promise { + const conv = this.host.activeConversation; + + if (!conv) return; + + const disabledTools = disabled.size ? [...disabled] : undefined; + + this.host.applyConversationUpdate(conv.id, { disabledTools }); + + await DatabaseService.updateConversation(conv.id, { disabledTools }); } } diff --git a/tools/ui/src/lib/stores/mcp/index.svelte.ts b/tools/ui/src/lib/stores/mcp/index.svelte.ts index ccd53bc9d2..ce9c4fbe3a 100644 --- a/tools/ui/src/lib/stores/mcp/index.svelte.ts +++ b/tools/ui/src/lib/stores/mcp/index.svelte.ts @@ -37,7 +37,7 @@ import type { Tool, ToolExecutionResult } from '$lib/types'; -import type { DatabaseMessageExtraMcpResource, McpServerOverride } from '$lib/types/database'; +import type { DatabaseMessageExtraMcpResource } from '$lib/types/database'; import type { SettingsConfigType } from '$lib/types/settings'; import { detectMcpTransportFromUrl, @@ -306,12 +306,12 @@ class MCPStore implements McpHealthHost { return extras; } - async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise { + async ensureInitialized(): Promise { if (!browser) { return false; } - const mcpConfig = this.buildMcpClientConfig(settingsStore.config, perChatOverrides); + const mcpConfig = this.buildMcpClientConfig(settingsStore.config); const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; if (!signature) { @@ -512,14 +512,6 @@ class MCPStore implements McpHealthHost { return this.connections; } - getEnabledServersForConversation( - perChatOverrides?: McpServerOverride[] - ): MCPServerSettingsEntry[] { - return this.getServers().filter((server) => { - return this.checkServerEnabled(server, perChatOverrides); - }); - } - /** * Check if a server already has an active connection that can be reused. * Returns the existing connection if available. @@ -811,8 +803,8 @@ class MCPStore implements McpHealthHost { ); } - hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean { - return Boolean(this.buildMcpClientConfig(settingsStore.config, perChatOverrides)); + hasEnabledServers(): boolean { + return Boolean(this.buildMcpClientConfig(settingsStore.config)); } /** @@ -820,18 +812,12 @@ class MCPStore implements McpHealthHost { * Uses health check state since servers may not have active connections until * the user actually sends a message or uses prompts. */ - hasPromptsCapability(perChatOverrides?: McpServerOverride[]): boolean { - let enabledServerIds: Set; - - if (perChatOverrides !== undefined) { - enabledServerIds = new Set(perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)); - } else { - enabledServerIds = new Set( - this.getServers() - .filter((s) => s.enabled) - .map((s) => s.id) - ); - } + hasPromptsCapability(): boolean { + const enabledServerIds = new Set( + this.getServers() + .filter((s) => s.enabled) + .map((s) => s.id) + ); if (enabledServerIds.size === 0) { return false; @@ -874,18 +860,12 @@ class MCPStore implements McpHealthHost { * Uses health check state since servers may not have active connections until * the user actually sends a message or uses prompts. */ - hasResourcesCapability(perChatOverrides?: McpServerOverride[]): boolean { - let enabledServerIds: Set; - - if (perChatOverrides !== undefined) { - enabledServerIds = new Set(perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)); - } else { - enabledServerIds = new Set( - this.getServers() - .filter((s) => s.enabled) - .map((s) => s.id) - ); - } + hasResourcesCapability(): boolean { + const enabledServerIds = new Set( + this.getServers() + .filter((s) => s.enabled) + .map((s) => s.id) + ); if (enabledServerIds.size === 0) { return false; @@ -1185,10 +1165,7 @@ class MCPStore implements McpHealthHost { /** * Builds MCP client configuration from settings. */ - private buildMcpClientConfig( - cfg: SettingsConfigType, - perChatOverrides?: McpServerOverride[] - ): MCPClientConfig | undefined { + private buildMcpClientConfig(cfg: SettingsConfigType): MCPClientConfig | undefined { const rawServers = parseMcpServerSettings(cfg.mcpServers); if (!rawServers.length) { @@ -1198,7 +1175,7 @@ class MCPStore implements McpHealthHost { const servers: Record = {}; for (const [index, entry] of rawServers.entries()) { - if (!this.checkServerEnabled(entry, perChatOverrides)) continue; + if (!entry.enabled) continue; const normalized = this.buildServerConfig(entry); @@ -1252,20 +1229,6 @@ class MCPStore implements McpHealthHost { }; } - /** - * Checks if a server is enabled for a given chat. - * A per-chat override wins when present; a server without one resolves - * to its own `enabled` flag in `mcpServers`. - */ - private checkServerEnabled( - server: MCPServerSettingsEntry, - perChatOverrides?: McpServerOverride[] - ): boolean { - const override = perChatOverrides?.find((o) => o.serverId === server.id); - - return override?.enabled ?? server.enabled; - } - private createListChangedHandlers(serverName: string): ListChangedHandlers { return { prompts: { diff --git a/tools/ui/src/lib/stores/tools.svelte.ts b/tools/ui/src/lib/stores/tools.svelte.ts index e255b8a43e..dbbaee05a1 100644 --- a/tools/ui/src/lib/stores/tools.svelte.ts +++ b/tools/ui/src/lib/stores/tools.svelte.ts @@ -12,6 +12,7 @@ import { buildBrowserInfoToolDefinition, buildGetDatetimeToolDefinition, buildReadMediaToolDefinition, + DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY, DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY, HOME_TILDE, TOOL_GROUP_LABELS, @@ -37,6 +38,9 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity'; /** Stable selection identity for a tool, shared by the disabled set and the permission store */ class ToolsStore { + // default disabled tool categories, seeded into newly created conversations; + // the per-conversation policy lives on the conversation row + private _disabledToolCategories = $state(new SvelteSet()); private _disabledTools = $state(new SvelteSet()); private _error = $state(null); private _loading = $state(false); @@ -150,6 +154,10 @@ class ToolsStore { } } + get disabledToolCategories(): ReadonlySet { + return this._disabledToolCategories; + } + get disabledTools(): SvelteSet { return this._disabledTools; } @@ -158,26 +166,6 @@ class ToolsStore { return this._error; } - /** - * Check if a working directory is worth setting: at least one server tool - * that reads it is both served and left enabled by the user. - */ - get hasEnabledCwdTools(): boolean { - return this._serverTools.some((def) => { - const name = def.function.name; - - return ( - this.cwdAwareTools.has(name) && - !this._disabledTools.has(this.toolKey(ToolSource.SERVER, name)) - ); - }); - } - - /** Check if there are any enabled tools available (server, MCP, or custom) */ - get hasEnabledTools(): boolean { - return this.getEnabledToolsForLLM().length > 0; - } - get isToolsEndpointUnreachable(): boolean { return this._toolsEndpointUnreachable; } @@ -271,17 +259,14 @@ class ToolsStore { } } - /** - * Enabled tool definitions for sending to the LLM. - * MCP tool schemas are normalized here so the wire payload is consistent - * across all four sources (server, browser/sandbox, MCP, custom JSON). - * The API identifies tools by name, so a name is sent at most once. - */ - getEnabledToolsForLLM(): OpenAIToolDefinition[] { + getEnabledToolsForLLM( + disabledTools: ReadonlySet = this._disabledTools, + disabledCategories: ReadonlySet = this._disabledToolCategories + ): OpenAIToolDefinition[] { const enabledNames = new SvelteSet(); for (const entry of this.allTools) { - if (!this._disabledTools.has(entry.key)) { + if (this.isEntryEnabled(entry, disabledTools, disabledCategories)) { enabledNames.add(entry.definition.function.name); } } @@ -306,6 +291,11 @@ class ToolsStore { return result; } + /** Server-scoped tool key: disabling it disables all of that server's tools. */ + getMcpServerToolsKey(serverId: string): string { + return `mcp:${serverId}`; + } + /** Permission key for a tool name, identical to the selection key */ getPermissionKey(toolName: string): string | null { return this.findEntryByName(toolName)?.key ?? null; @@ -333,6 +323,26 @@ class ToolsStore { return this.findEntryByName(toolName)?.source ?? null; } + /** + * Check if a working directory is worth setting: at least one server tool + * that reads it is both served and left enabled by the given policy + * (defaults to the global defaults). + */ + hasEnabledCwdTools( + disabledTools: ReadonlySet = this._disabledTools, + disabledCategories: ReadonlySet = this._disabledToolCategories + ): boolean { + if (disabledCategories.has(ToolSource.SERVER)) return false; + + return this._serverTools.some((def) => { + const name = def.function.name; + + return ( + this.cwdAwareTools.has(name) && !disabledTools.has(this.toolKey(ToolSource.SERVER, name)) + ); + }); + } + /** * Load persisted disabled tools and fetch the builtin tool list. * Called by initStores() after migrations have run. @@ -357,11 +367,56 @@ class ToolsStore { console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err); } + try { + const stored = localStorage.getItem(DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY); + + if (stored) { + const parsed = JSON.parse(stored); + + if (Array.isArray(parsed)) { + for (const key of parsed) { + if (Object.values(ToolSource).includes(key)) { + this._disabledToolCategories.add(key as ToolSource); + } + } + } + } + } catch (err) { + console.error('[ToolsStore] Failed to load disabled tool categories from localStorage:', err); + } + this.fetchServerTools(); } - isGroupFullyEnabled(group: ToolGroup): boolean { - return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key)); + isCategoryEnabled(source: ToolSource): boolean { + return !this._disabledToolCategories.has(source); + } + + /** + * Enabled tool definitions for sending to the LLM. + * MCP tool schemas are normalized here so the wire payload is consistent + * across all four sources (server, browser/sandbox, MCP, custom JSON). + * The API identifies tools by name, so a name is sent at most once. + */ + /** + * The single enable rule for a tool entry: its category must be on, its + * own key must not be disabled, and for MCP tools the server-scoped group + * key must not be disabled either. + */ + isEntryEnabled( + entry: ToolEntry, + disabledTools: ReadonlySet, + disabledCategories: ReadonlySet + ): boolean { + if (disabledCategories.has(entry.source)) return false; + + if (disabledTools.has(entry.key)) return false; + + if (entry.source === ToolSource.MCP && entry.serverId) { + return !disabledTools.has(this.getMcpServerToolsKey(entry.serverId)); + } + + return true; } isToolEnabled(key: string): boolean { @@ -394,6 +449,16 @@ class ToolsStore { return this._serverHome; } + setCategoryEnabled(source: ToolSource, enabled: boolean): void { + if (enabled) { + this._disabledToolCategories.delete(source); + } else { + this._disabledToolCategories.add(source); + } + + this.persistDisabledToolCategories(); + } + setToolEnabled(key: string, enabled: boolean): void { if (enabled) { this._disabledTools.delete(key); @@ -402,15 +467,8 @@ class ToolsStore { } } - toggleGroup(group: ToolGroup): void { - const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key)); - const target = !allEnabled; - - for (const tool of group.tools) { - if (target) this._disabledTools.delete(tool.key); - else this._disabledTools.add(tool.key); - } - this.persistDisabledTools(); + toggleCategory(source: ToolSource): void { + this.setCategoryEnabled(source, !this.isCategoryEnabled(source)); } toggleTool(key: string): void { @@ -602,6 +660,17 @@ class ToolsStore { return normalized; } + private persistDisabledToolCategories(): void { + try { + localStorage.setItem( + DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY, + JSON.stringify([...this._disabledToolCategories]) + ); + } catch { + // ignore storage errors + } + } + private persistDisabledTools(): void { try { localStorage.setItem( diff --git a/tools/ui/src/lib/types/agentic.d.ts b/tools/ui/src/lib/types/agentic.d.ts index 1a604476bf..c60e283fb6 100644 --- a/tools/ui/src/lib/types/agentic.d.ts +++ b/tools/ui/src/lib/types/agentic.d.ts @@ -15,7 +15,7 @@ import type { DatabaseMessageExtraAudioFile, DatabaseMessageExtraImageFile } from './database'; -import type { MessageRole } from '$lib/enums'; +import type { MessageRole, ToolSource } from '$lib/enums'; import { AgenticSectionType, ContinueIntentKind, ToolCallType } from '$lib/enums'; /** @@ -162,6 +162,12 @@ export interface AgenticFlowOptions { /** * Parameters for starting an agentic flow */ +/** Per-conversation tool policy, captured at flow start */ +export interface AgenticToolPolicy { + disabledToolCategories: ToolSource[]; + disabledTools: string[]; +} + export interface AgenticFlowParams { conversationId: string; /** ID of the flow's first assistant message, used to keep its stats live */ @@ -170,7 +176,7 @@ export interface AgenticFlowParams { options?: AgenticFlowOptions; callbacks: AgenticFlowCallbacks; signal?: AbortSignal; - perChatOverrides?: McpServerOverride[]; + toolPolicy?: AgenticToolPolicy; } /** diff --git a/tools/ui/src/lib/types/database.d.ts b/tools/ui/src/lib/types/database.d.ts index b239aa0251..57b77f06cd 100644 --- a/tools/ui/src/lib/types/database.d.ts +++ b/tools/ui/src/lib/types/database.d.ts @@ -1,6 +1,11 @@ -import { AttachmentType, ReasoningEffort } from '$lib/enums'; +import { AttachmentType, ReasoningEffort, ToolSource } from '$lib/enums'; import type { ChatMessageTimings, ChatMessageType, ChatRole } from '$lib/types/chat'; +/** + * @deprecated Legacy per-conversation MCP server flags. MCP server enabled + * state is global now; per-conversation tool policy lives in + * `disabledTools` / `disabledToolCategories`. Read by the migration only. + */ export interface McpServerOverride { serverId: string; enabled: boolean; @@ -11,10 +16,15 @@ export interface DatabaseConversation { id: string; lastModified: number; name: string; + /** @deprecated See {@link McpServerOverride}. Kept on rows for downgrade compatibility. */ mcpServerOverrides?: McpServerOverride[]; thinkingEnabled?: boolean; reasoningEffort?: ReasoningEffort; cwd?: string; + /** Tool keys disabled for this conversation, incl. server-scoped MCP group keys (`mcp:`) */ + disabledTools?: string[]; + /** Tool categories disabled for this conversation */ + disabledToolCategories?: ToolSource[]; forkedFromConversationId?: string; pinned?: boolean; } diff --git a/tools/ui/tests/unit/mcp-override-fallback.test.ts b/tools/ui/tests/unit/mcp-override-fallback.test.ts deleted file mode 100644 index 12ed6e4c4b..0000000000 --- a/tools/ui/tests/unit/mcp-override-fallback.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { CONFIG_LOCALSTORAGE_KEY, SETTINGS_KEYS } from '$lib/constants'; -import type { DatabaseConversation } from '$lib/types/database'; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; - -// node env unit project has no DOM, install a minimal localStorage backed by a Map -beforeAll(() => { - const store = new Map(); - const polyfill: Storage = { - clear: () => store.clear(), - getItem: (k) => (store.has(k) ? store.get(k)! : null), - key: (i) => Array.from(store.keys())[i] ?? null, - get length() { - return store.size; - }, - removeItem: (k) => { - store.delete(k); - }, - setItem: (k, v) => { - store.set(k, String(v)); - } - }; - - (globalThis as unknown as { localStorage: Storage }).localStorage = polyfill; -}); - -/** - * Regression coverage for the bug where MCP servers flipped to "disabled" - * after sending the first message on a fresh chat (see comment in - * `MCPStore.createConversation`: empty `mcpServerOverrides` should inherit - * `mcpServers[i].enabled`, not be treated as all-off). - */ -describe('conversationsStore MCP override resolution', () => { - beforeEach(async () => { - localStorage.clear(); - // Two configured servers: alpha is globally disabled, bravo enabled. - localStorage.setItem( - CONFIG_LOCALSTORAGE_KEY, - JSON.stringify({ - [SETTINGS_KEYS.MCP_SERVERS]: JSON.stringify([ - { enabled: false, id: 'alpha', url: 'https://alpha.example.com/mcp' }, - { enabled: true, id: 'bravo', url: 'https://bravo.example.com/mcp' } - ]) - }) - ); - - // The settings store constructor bails in node env (no `browser`), - // so seed the config directly. The shape mirrors what `loadConfig` - // would build from localStorage. - const { settingsStore } = await import('$lib/stores/settings/index.svelte'); - const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}'; - const saved = JSON.parse(raw) as Record; - - settingsStore.config = { - ...settingsStore.config, - [SETTINGS_KEYS.MCP_SERVERS]: saved[SETTINGS_KEYS.MCP_SERVERS] - }; - }); - - afterEach(() => { - localStorage.clear(); - }); - - function makeConversation( - overrides?: { serverId: string; enabled: boolean }[] - ): DatabaseConversation { - return { - currNode: null, - id: 'conv-1', - lastModified: 0, - mcpServerOverrides: overrides, - name: 'Test chat' - }; - } - - it('inherits server.enabled when no conversation is active', async () => { - const { conversationsStore } = await import('$lib/stores/conversations/index.svelte'); - - conversationsStore.activeConversation = null; - - expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false); - expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true); - }); - - it('inherits server.enabled on a newly created chat with no overrides', async () => { - const { conversationsStore } = await import('$lib/stores/conversations/index.svelte'); - - conversationsStore.activeConversation = makeConversation(); - - // Empty override list: must fall back to global server.enabled, not all-off. - expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false); - expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true); - }); - - it('inherits server.enabled on a newly created chat when overrides is undefined', async () => { - const { conversationsStore } = await import('$lib/stores/conversations/index.svelte'); - - conversationsStore.activeConversation = makeConversation(undefined); - - expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false); - expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true); - }); - - it('uses explicit per-chat overrides, with defaults for non-overridden servers', async () => { - const { conversationsStore } = await import('$lib/stores/conversations/index.svelte'); - - // Override flips bravo off for this chat, alpha keeps its global default. - conversationsStore.activeConversation = makeConversation([ - { enabled: false, serverId: 'bravo' } - ]); - - expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false); - expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(false); - }); - - it('getAllMcpServerOverrides returns a complete list merged from defaults', async () => { - const { conversationsStore } = await import('$lib/stores/conversations/index.svelte'); - - conversationsStore.activeConversation = makeConversation([ - { enabled: true, serverId: 'alpha' } - ]); - - expect(conversationsStore.preferences.getAllMcpServerOverrides()).toEqual([ - { enabled: true, serverId: 'alpha' }, - { enabled: true, serverId: 'bravo' } - ]); - }); - - it('getAllMcpServerOverrides falls back to defaults when there are no explicit overrides', async () => { - const { conversationsStore } = await import('$lib/stores/conversations/index.svelte'); - - conversationsStore.activeConversation = makeConversation(); - - expect(conversationsStore.preferences.getAllMcpServerOverrides()).toEqual([ - { enabled: false, serverId: 'alpha' }, - { enabled: true, serverId: 'bravo' } - ]); - }); - - it('getMcpServerOverride returns the global default when the server has no explicit override', async () => { - const { conversationsStore } = await import('$lib/stores/conversations/index.svelte'); - - conversationsStore.activeConversation = makeConversation([ - { enabled: true, serverId: 'alpha' } - ]); - - expect(conversationsStore.preferences.getMcpServerOverride('bravo')).toEqual({ - enabled: true, - serverId: 'bravo' - }); - }); -});