+
Chat Template
-
-
-
{serverProps.chat_template}
+
+
{serverProps.chat_template}
+
{/if}
+
+
+
+
+
+
+
File Path
+
+
+
{serverProps.model_path}
+
+
+
+
+
+ {#if serverProps?.default_generation_settings?.n_ctx}
+ {@render infoRow(
+ 'Context Size',
+ `${formatNumber(serverProps.default_generation_settings.n_ctx)} tokens`
+ )}
+ {:else}
+ {@render infoRow('Context Size', 'Not available', 'text-red-500')}
+ {/if}
+
+ {#if modelMeta?.n_ctx_train}
+ {@render infoRow('Training Context', `${formatNumber(modelMeta.n_ctx_train)} tokens`)}
+ {/if}
+
+ {#if modelMeta?.size}
+ {@render infoRow('Model Size', formatFileSize(modelMeta.size))}
+ {/if}
+
+ {#if modelMeta?.n_params}
+ {@render infoRow('Parameters', formatParameters(modelMeta.n_params))}
+ {/if}
+
+ {#if modelMeta?.n_embd}
+ {@render infoRow('Embedding Size', formatNumber(modelMeta.n_embd))}
+ {/if}
+
+ {#if modelMeta?.n_vocab}
+ {@render infoRow('Vocabulary Size', `${formatNumber(modelMeta.n_vocab)} tokens`)}
+ {/if}
+
+ {#if modelMeta?.vocab_type}
+ {@render infoRow('Vocabulary Type', modelMeta.vocab_type, 'capitalize')}
+ {/if}
+
+ {@render infoRow('Parallel Slots', `${serverProps.total_slots}`)}
+
+ {#if modalities.length > 0}
+
+ {/if}
+
+
+
Build Info
+
+
{serverProps.build_info}
+
+
+ {#if serverProps.chat_template}
+
+
Chat Template
+
+
+
{serverProps.chat_template}
+
+
+ {/if}
+
{/if}
{:else if !isLoadingModels}
@@ -272,3 +359,11 @@
+
+{#snippet infoRow(label: string, value: string, valueClass: string = '')}
+
+ {label}
+
+ {value}
+
+{/snippet}
diff --git a/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte b/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte
index ea274d5aa7..3319dcf09f 100644
--- a/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte
+++ b/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte
@@ -2,7 +2,7 @@
import McpLogo from './McpLogo.svelte';
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 { HealthCheckStatus, ToolSource } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
interface Props {
@@ -13,9 +13,13 @@
let { class: className = '', onclick }: Props = $props();
let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
+ // respect the active conversation's tool policy, not just global enablement
let enabledMcpServersForChat = $derived(
mcpServers.filter(
- (s) => conversationsStore.preferences.isMcpServerEnabledForChat(s.id) && s.url.trim()
+ (s) =>
+ s.url.trim() &&
+ conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP) &&
+ conversationsStore.preferences.isServerToolsEnabled(s.id)
)
);
let healthyEnabledMcpServers = $derived(
diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte
index 634497524a..4c07553bcf 100644
--- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte
+++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte
@@ -25,6 +25,10 @@
No tools available
{:else}
+
+ Applies to new conversations. Tool picks inside a chat only affect that chat.
+
+
{#each groups as group (group.key)}
{@const isExpanded = expandedGroups.has(group.key)}
toggleExpanded(group.key)} open={isExpanded}>
@@ -37,6 +41,17 @@
{/if}
+ {@const isCategoryEnabled =
+ group.source !== ToolSource.MCP && toolsStore.isCategoryEnabled(group.source)}
+
+ {#if group.source !== ToolSource.MCP}
+ toolsStore.toggleCategory(group.source)}
+ onclick={(e) => e.stopPropagation()}
+ />
+ {/if}
+
{@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
diff --git a/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte b/tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte
index dd4b96c626..c0cf0000d3 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,13 @@
{: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
diff --git a/tools/ui/src/lib/components/ui/checkbox/checkbox.svelte b/tools/ui/src/lib/components/ui/checkbox/checkbox.svelte
index ec6d28826e..60ef646fc4 100644
--- a/tools/ui/src/lib/components/ui/checkbox/checkbox.svelte
+++ b/tools/ui/src/lib/components/ui/checkbox/checkbox.svelte
@@ -26,10 +26,10 @@
>
{#snippet children({ checked, indeterminate })}
- {#if checked}
-
- {:else if indeterminate}
+ {#if indeterminate}
+ {:else if checked}
+
{/if}
{/snippet}
diff --git a/tools/ui/src/lib/constants/attachment-menu.constants.ts b/tools/ui/src/lib/constants/attachment-menu.constants.ts
index 07ca17fad1..143450a752 100644
--- a/tools/ui/src/lib/constants/attachment-menu.constants.ts
+++ b/tools/ui/src/lib/constants/attachment-menu.constants.ts
@@ -1,11 +1,5 @@
-import { FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import { FILE_TYPE_ICONS } from '$lib/constants';
-import {
- AttachmentAction,
- AttachmentItemEnabledWhen,
- AttachmentItemVisibleWhen,
- AttachmentMenuItemId
-} from '$lib/enums';
+import { AttachmentAction, AttachmentItemEnabledWhen, AttachmentMenuItemId } from '$lib/enums';
import type { AttachmentMenuItem } from '$lib/types';
/**
@@ -58,36 +52,4 @@ export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
}
];
-export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [];
-
-export const ATTACHMENT_PROMPT_ITEMS: AttachmentMenuItem[] = [
- {
- action: AttachmentAction.SYSTEM_PROMPT_CLICK,
- enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
- hasEnabledTooltip: true,
- icon: MessageSquare,
- id: AttachmentMenuItemId.SYSTEM_MESSAGE,
- label: 'System Message'
- },
- {
- action: AttachmentAction.MCP_PROMPT_CLICK,
- enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
- icon: Zap,
- id: AttachmentMenuItemId.MCP_PROMPT,
- label: 'MCP Prompts',
- visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
- }
-];
-
-export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
- {
- action: AttachmentAction.MCP_RESOURCES_CLICK,
- enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
- icon: FolderOpen,
- id: AttachmentMenuItemId.MCP_RESOURCES,
- label: 'MCP Resources',
- visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_RESOURCES_SUPPORT
- }
-];
-
export const ATTACHMENT_TOOLTIP_TEXT = 'Add files, prompts, tools or MCP Servers';
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/enums/attachment.enums.ts b/tools/ui/src/lib/enums/attachment.enums.ts
index 70ed36d89f..5f096b9744 100644
--- a/tools/ui/src/lib/enums/attachment.enums.ts
+++ b/tools/ui/src/lib/enums/attachment.enums.ts
@@ -19,8 +19,6 @@ export enum AttachmentType {
export enum AttachmentMenuItemId {
AUDIO = 'audio',
IMAGES = 'images',
- MCP_PROMPT = 'mcp-prompt',
- MCP_RESOURCES = 'mcp-resources',
PDF = 'pdf',
SYSTEM_MESSAGE = 'system-message',
TEXT = 'text',
@@ -42,8 +40,6 @@ export enum AttachmentItemEnabledWhen {
*/
export enum AttachmentAction {
FILE_UPLOAD = 'onFileUpload',
- MCP_PROMPT_CLICK = 'onMcpPromptClick',
- MCP_RESOURCES_CLICK = 'onMcpResourcesClick',
SYSTEM_PROMPT_CLICK = 'onSystemPromptClick'
}
@@ -56,11 +52,3 @@ export enum AttachmentLabel {
MCP_RESOURCE = 'MCP Resource',
PDF_FILE = 'PDF File'
}
-
-/**
- * Visibility conditions for attachment menu items.
- */
-export enum AttachmentItemVisibleWhen {
- HAS_MCP_PROMPTS_SUPPORT = 'hasMcpPromptsSupport',
- HAS_MCP_RESOURCES_SUPPORT = 'hasMcpResourcesSupport'
-}
diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 89105c6b08..efe5789e20 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -3,8 +3,7 @@ export {
AttachmentType,
AttachmentMenuItemId,
AttachmentItemEnabledWhen,
- AttachmentAction,
- AttachmentItemVisibleWhen
+ AttachmentAction
} from './attachment.enums';
export {
diff --git a/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts b/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts
index 98ecc9ace0..c738c266a6 100644
--- a/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts
@@ -5,21 +5,16 @@ export interface AttachmentModalityFlags {
hasVisionModality: boolean;
hasAudioModality: boolean;
hasVideoModality: boolean;
- hasMcpPromptsSupport: boolean;
- hasMcpResourcesSupport: boolean;
}
export interface AttachmentActionCallbacks {
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
- onMcpPromptClick?: () => void;
- onMcpResourcesClick?: () => void;
}
export interface UseAttachmentMenuReturn {
readonly callbacks: Record void>;
isItemEnabled(enabledWhen: string | undefined): boolean;
- isItemVisible(visibleWhen: string | undefined): boolean;
getSystemMessageTooltip(): string;
}
@@ -49,8 +44,6 @@ export function useAttachmentMenu(
return {
[AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload),
- [AttachmentAction.MCP_PROMPT_CLICK]: wrap(cbs.onMcpPromptClick),
- [AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick),
[AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick)
};
});
@@ -61,12 +54,6 @@ export function useAttachmentMenu(
return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags];
}
- function isItemVisible(visibleWhen: string | undefined): boolean {
- if (!visibleWhen) return true;
-
- return !!modalityFlags[visibleWhen as keyof AttachmentModalityFlags];
- }
-
function getSystemMessageTooltip(): string {
return !page.params.id
? 'Add custom system message for a new conversation'
@@ -78,7 +65,6 @@ export function useAttachmentMenu(
return callbacks;
},
getSystemMessageTooltip,
- isItemEnabled,
- isItemVisible
+ isItemEnabled
};
}
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..21deed32d3 100644
--- a/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
@@ -1,19 +1,23 @@
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;
isGroupChecked(group: ToolGroup): boolean;
getEnabledToolCount(group: ToolGroup): number;
+ getGroupCheckState(group: ToolGroup): { checked: boolean; indeterminate: boolean };
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;
@@ -26,19 +30,18 @@ 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)
- )
- );
- const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
+ // 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 noToolsInfoMessage = $derived.by(() => {
if (toolsStore.loading) return null;
@@ -56,11 +59,27 @@ 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;
+ }
+
+ /**
+ * Group checkbox state: checked is the parent flag (category on, or the
+ * server key on for MCP groups); indeterminate marks the mixed case where
+ * the parent is on but nothing or only part of the group is enabled.
+ * isToolActive folds the parent gates into the count, so a disabled parent
+ * always yields plain unchecked.
+ */
+ function getGroupCheckState(group: ToolGroup): { checked: boolean; indeterminate: boolean } {
+ const checked = isGroupChecked(group);
+ const enabledCount = getEnabledToolCount(group);
+ const indeterminate =
+ group.tools.length > 0 && (enabledCount === 0 ? checked : enabledCount < group.tools.length);
+
+ return { checked, indeterminate };
}
function getFavicon(group: ToolGroup): string | null {
@@ -70,13 +89,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 +118,11 @@ 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 handleOpen(): void {
@@ -103,23 +134,27 @@ export function useToolsPanel(): UseToolsPanelReturn {
}
return {
- get activeGroups() {
- return activeGroups;
+ get categoryGroups() {
+ return categoryGroups;
},
expandedGroups,
getEnabledToolCount,
getFavicon,
- get groups() {
- return groups;
- },
+ getGroupCheckState,
handleOpen,
isGroupChecked,
isGroupDisabled,
+ isToolEnabled,
+ isToolParentDisabled,
+ get mcpGroups() {
+ return mcpGroups;
+ },
get noToolsInfoMessage() {
return noToolsInfoMessage;
},
toggleGroupByKey,
toggleGroupExpanded,
+ 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..f78f3be626 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 {
+ // fall through with empty defaults so legacy overrides still migrate
+ }
+
+ 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..50db1be0cc 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,28 @@ 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);
+ // callers without an explicit policy fall back to the global defaults
+ const disabledTools = new Set(toolPolicy?.disabledTools ?? toolsStore.disabledTools);
+ const disabledToolCategories = new Set(
+ toolPolicy?.disabledToolCategories ?? toolsStore.disabledToolCategories
+ );
+ // initialize every settings-enabled server; tool collection filters by this
+ // flow's policy, so switching policies never re-initializes connections
+ const hasMcpServers = conversationsStore.preferences.policyEnabledServerIds().length > 0;
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(disabledTools, 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..916911a145 100644
--- a/tools/ui/src/lib/stores/conversations/preferences.svelte.ts
+++ b/tools/ui/src/lib/stores/conversations/preferences.svelte.ts
@@ -1,21 +1,23 @@
/**
* 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 +50,26 @@ export interface ConversationsPreferencesHost {
applyConversationUpdate(id: string, updates: Partial): void;
}
+/**
+ * Effective disabled tool keys: the active conversation row, or the global
+ * defaults when there is no conversation. An existing row with an unset
+ * field has an empty policy, not a fallback to defaults.
+ */
+function buildDisabledTools(conv: DatabaseConversation | null): Set {
+ return new Set(conv ? (conv.disabledTools ?? []) : [...toolsStore.disabledTools]);
+}
+
+/**
+ * Effective disabled tool categories: the active conversation row, or the
+ * global defaults when there is no conversation. An existing row with an
+ * unset field has an empty policy, not a fallback 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 +83,29 @@ export class ConversationPreferences {
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state(loadReasoningEffortDefault());
- constructor(private host: ConversationsPreferencesHost) {}
-
- /**
- * 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 };
- });
+ private get _disabledToolCategories(): Set {
+ return buildDisabledToolCategories(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.
- */
- getMcpServerOverride(serverId: string): McpServerOverride | undefined {
- const override = this.host.activeConversation?.mcpServerOverrides?.find(
- (o: McpServerOverride) => o.serverId === serverId
- );
+ // Tool Policy
- if (override) return override;
+ // 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);
+ }
- return this.getDefaultOverride(serverId);
+ constructor(private host: ConversationsPreferencesHost) {}
+
+ /** 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 +129,71 @@ 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)
+ );
+ }
+
+ /**
+ * MCP servers usable under the effective policy: globally enabled, url set,
+ * MCP category on and the server-scoped key not disabled.
+ */
+ policyEnabledServerIds(): string[] {
+ if (!this.isCategoryEnabled(ToolSource.MCP)) return [];
+
+ return mcpStore
+ .getServers()
+ .filter(
+ (server) => server.enabled && server.url.trim() && this.isServerToolsEnabled(server.id)
+ )
+ .map((server) => server.id);
}
/** Reload persisted defaults, e.g. when the active conversation is cleared. */
@@ -132,6 +202,8 @@ export class ConversationPreferences {
this.pendingCwd = null;
}
+ // Working Directory
+
/**
* Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state.
@@ -165,56 +237,7 @@ 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
- });
- }
+ // Reasoning Effort
/**
* Sets the reasoning effort for the active conversation.
@@ -229,33 +252,82 @@ 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);
}
- /**
- * Resolve the default enabled value for a server: its own `enabled`
- * flag in `mcpServers`, so the global on/off state lives in one place.
- */
- private getDefaultOverride(serverId: string): McpServerOverride | undefined {
- const server = mcpStore.getServers().find((s) => s.id === serverId);
+ async toggleGroup(group: ToolGroup): Promise {
+ if (group.source === ToolSource.MCP && group.serverId) {
+ await this.toggleServerTools(group.serverId);
+ } else {
+ await this.toggleCategory(group.source);
+ }
+ }
- if (!server) return undefined;
+ async toggleServerTools(serverId: string): Promise {
+ await this.toggleTool(toolsStore.getMcpServerToolsKey(serverId));
+ }
- return { enabled: server.enabled, serverId };
+ async toggleTool(key: string): Promise {
+ const conv: DatabaseConversation | null = this.host.activeConversation;
+
+ 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 });
+ }
+
+ 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..768e2c1aa0 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,16 @@ class MCPStore implements McpHealthHost {
return extras;
}
- async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise {
+ /**
+ * Initialize every settings-enabled server. Policy filtering happens at tool
+ * collection time, so switching conversation policies never re-initializes.
+ */
+ 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 +516,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,106 +807,8 @@ class MCPStore implements McpHealthHost {
);
}
- hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
- return Boolean(this.buildMcpClientConfig(settingsStore.config, perChatOverrides));
- }
-
- /**
- * Check if any enabled server with successful health check supports prompts.
- * 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)
- );
- }
-
- if (enabledServerIds.size === 0) {
- return false;
- }
-
- for (const [serverId, state] of Object.entries(this.health.checks)) {
- if (!enabledServerIds.has(serverId)) continue;
-
- if (
- state.status === HealthCheckStatus.SUCCESS &&
- state.capabilities?.server?.prompts !== undefined
- ) {
- return true;
- }
- }
-
- for (const [serverName, connection] of this.connections) {
- if (!enabledServerIds.has(serverName)) continue;
-
- if (connection.serverCapabilities?.prompts) {
- return true;
- }
- }
-
- return false;
- }
-
- hasPromptsSupport(): boolean {
- for (const connection of this.connections.values()) {
- if (connection.serverCapabilities?.prompts) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Check if any enabled server with successful health check supports resources.
- * 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)
- );
- }
-
- if (enabledServerIds.size === 0) {
- return false;
- }
-
- for (const [serverId, state] of Object.entries(this.health.checks)) {
- if (!enabledServerIds.has(serverId)) continue;
-
- if (
- state.status === HealthCheckStatus.SUCCESS &&
- state.capabilities?.server?.resources !== undefined
- ) {
- return true;
- }
- }
-
- for (const [serverName, connection] of this.connections) {
- if (!enabledServerIds.has(serverName)) continue;
-
- if (MCPService.supportsResources(connection)) {
- return true;
- }
- }
-
- return false;
+ hasEnabledServers(): boolean {
+ return Boolean(this.buildMcpClientConfig(settingsStore.config));
}
/**
@@ -1185,10 +1083,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 +1093,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 +1147,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: {
@@ -1378,6 +1259,15 @@ class MCPStore implements McpHealthHost {
return `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
}
+ /** Server ids that are usable right now: globally enabled ones. */
+ private globalEnabledServerIds(): Set {
+ return new Set(
+ this.getServers()
+ .filter((s) => s.enabled)
+ .map((s) => s.id)
+ );
+ }
+
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName);
diff --git a/tools/ui/src/lib/stores/tools.svelte.ts b/tools/ui/src/lib/stores/tools.svelte.ts
index e255b8a43e..db05e3cd55 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;
}
@@ -233,9 +221,13 @@ class ToolsStore {
if (!connection) return;
+ // the server-scoped group key disables every tool regardless of per-tool keys
+ this._disabledTools.delete(this.getMcpServerToolsKey(serverId));
+
for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
}
+
this.persistDisabledTools();
}
@@ -272,16 +264,21 @@ class ToolsStore {
}
/**
- * Enabled tool definitions for sending to the LLM.
+ * Enabled tool definitions for sending to the LLM. Callers pass an
+ * explicit policy (the active conversation's, resolved with global
+ * defaults when absent); without arguments the store defaults apply.
* 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 +303,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 +335,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 +379,45 @@ 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);
+ }
+
+ 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,33 +450,32 @@ 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);
} else {
this._disabledTools.add(key);
}
+
+ this.persistDisabledTools();
}
- 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 {
- if (this._disabledTools.has(key)) {
- this._disabledTools.delete(key);
- } else {
- this._disabledTools.add(key);
- }
-
- this.persistDisabledTools();
+ this.setToolEnabled(key, !this.isToolEnabled(key));
}
/** First canonical entry matching a tool name, runtime tool calls resolve by name */
@@ -602,6 +657,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(
@@ -637,7 +703,9 @@ class ToolsStore {
private toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) {
case ToolSource.MCP:
- return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
+ // with a serverId this is a per-tool key; without one it hits the
+ // server group key shape, which no MCP entry ever does
+ return serverId ? `mcp-${serverId}:${name}` : this.getMcpServerToolsKey(name);
case ToolSource.CUSTOM:
return `custom:${name}`;
case ToolSource.BROWSER:
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/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts
index f0f3a297e8..274131dd43 100644
--- a/tools/ui/src/lib/types/chat.d.ts
+++ b/tools/ui/src/lib/types/chat.d.ts
@@ -3,7 +3,6 @@ import type { DatabaseMessage, DatabaseMessageExtra } from './database';
import type {
AttachmentAction,
AttachmentItemEnabledWhen,
- AttachmentItemVisibleWhen,
AttachmentMenuItemId,
ChatFormCommandAction,
ErrorDialogType,
@@ -30,8 +29,6 @@ export interface AttachmentMenuItem {
disabledTooltip?: string;
/** Callback key on the Props interface to invoke when clicked */
action: AttachmentAction;
- /** Whether the item is only shown when a specific capability is present */
- visibleWhen?: AttachmentItemVisibleWhen;
/** Whether this item has a tooltip even when enabled (uses dynamic text) */
hasEnabledTooltip?: boolean;
}
@@ -336,11 +333,7 @@ export interface ChatFormActionsContext {
readonly hasAudioModality: boolean;
readonly hasVideoModality: boolean;
readonly hasVisionModality: boolean;
- readonly hasMcpPromptsSupport: boolean;
- readonly hasMcpResourcesSupport: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
- onMcpPromptClick?: () => void;
- onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
}
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'
- });
- });
-});