webui: fix tool selector toggle/counter, key tools by stable identity (#24065)

* webui: fix tool selector toggle/counter, key tools by stable identity

Key the disabled set, counts and toggles by a stable per-tool key
instead of bare function name, deduped from one canonical list. Per-tool
checkboxes become presentational (single row handler, no nested button),
category checkboxes drop the tristate (n/total carries partial). One
getEnabledToolsForLLM keeps normalized MCP schemas and dedupes by name.

* ui: use SvelteSet and SvelteMap for local tool collections to satisfy svelte/prefer-svelte-reactivity
This commit is contained in:
Pascal 2026-06-04 13:09:49 +02:00 committed by GitHub
parent 4d742877b2
commit 4586479852
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 203 additions and 240 deletions

View File

@ -231,7 +231,7 @@
<Collapsible.Content> <Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4"> <div class="flex flex-col gap-0.5 pl-4">
{#each toolsPanel.activeGroups as group (group.label)} {#each toolsPanel.activeGroups as group (group.label)}
{@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)} {@const checked = toolsPanel.isGroupChecked(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)} {@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)} {@const favicon = toolsPanel.getFavicon(group)}
@ -259,7 +259,6 @@
<Checkbox <Checkbox
{checked} {checked}
{indeterminate}
class="h-4 w-4 shrink-0" class="h-4 w-4 shrink-0"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)} onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info } from '@lucide/svelte'; import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible'; import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@ -65,7 +65,7 @@
<div class="max-h-80 overflow-y-auto p-2 pr-1"> <div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each toolsPanel.activeGroups as group (group.label)} {#each toolsPanel.activeGroups as group (group.label)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.label)} {@const isExpanded = toolsPanel.expandedGroups.has(group.label)}
{@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)} {@const checked = toolsPanel.isGroupChecked(group)}
{@const favicon = toolsPanel.getFavicon(group)} {@const favicon = toolsPanel.getFavicon(group)}
<Collapsible.Root <Collapsible.Root
@ -104,12 +104,14 @@
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
<Checkbox {#snippet child({ props })}
{checked} <Checkbox
{indeterminate} {...props}
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)} {checked}
class="mr-2 h-4 w-4 shrink-0" onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
/> class="mr-2 h-4 w-4 shrink-0"
/>
{/snippet}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="right"> <Tooltip.Content side="right">
@ -123,20 +125,25 @@
<Collapsible.Content> <Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2"> <div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as tool (tool.function.name)} {#each group.tools as entry (entry.key)}
{@const enabled = toolsStore.isToolEnabled(entry.key)}
<button <button
type="button" type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50" class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
onclick={() => toolsStore.toggleTool(tool.function.name)} onclick={() => toolsStore.toggleTool(entry.key)}
> >
<Checkbox <span
checked={toolsStore.isToolEnabled(tool.function.name)} data-slot="checkbox"
onCheckedChange={() => toolsStore.toggleTool(tool.function.name)} data-state={enabled ? 'checked' : 'unchecked'}
class="h-4 w-4 shrink-0" class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
/> >
{#if enabled}
<Check class="size-3.5" />
{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-[12px]"> <span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{tool.function.name} {entry.definition.function.name}
</span> </span>
</button> </button>
{/each} {/each}

View File

@ -62,13 +62,11 @@
<span class="w-20 shrink-0 text-center">Always allow</span> <span class="w-20 shrink-0 text-center">Always allow</span>
</div> </div>
{#each group.tools as tool (tool.function.name)} {#each group.tools as entry (entry.key)}
{@const toolName = tool.function.name} {@const toolName = entry.definition.function.name}
{@const isEnabled = toolsStore.isToolEnabled(toolName)} {@const isEnabled = toolsStore.isToolEnabled(entry.key)}
{@const permissionKey = toolsStore.getPermissionKey(toolName)} {@const permissionKey = entry.key}
{@const isAlwaysAllowed = permissionKey {@const isAlwaysAllowed = permissionsStore.hasTool(permissionKey)}
? permissionsStore.hasTool(permissionKey)
: false}
<div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"> <div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
<TruncatedText text={toolName} class="flex-1" showTooltip={true} /> <TruncatedText text={toolName} class="flex-1" showTooltip={true} />
@ -76,7 +74,7 @@
<div class="flex w-16 shrink-0 justify-center"> <div class="flex w-16 shrink-0 justify-center">
<Checkbox <Checkbox
checked={isEnabled} checked={isEnabled}
onCheckedChange={() => toolsStore.toggleTool(toolName)} onCheckedChange={() => toolsStore.toggleTool(entry.key)}
class="h-4 w-4" class="h-4 w-4"
/> />
</div> </div>
@ -86,9 +84,9 @@
checked={isAlwaysAllowed} checked={isAlwaysAllowed}
onCheckedChange={() => { onCheckedChange={() => {
if (isAlwaysAllowed) { if (isAlwaysAllowed) {
permissionsStore.revokeTool(permissionKey!); permissionsStore.revokeTool(permissionKey);
} else { } else {
permissionsStore.allowTool(permissionKey!); permissionsStore.allowTool(permissionKey);
} }
}} }}
class="h-4 w-4" class="h-4 w-4"

View File

@ -17,6 +17,9 @@ export const DB_APP_NAME_DEPRECATED = 'LlamacppWebui';
export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.alwaysAllowedTools`; export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.alwaysAllowedTools`;
export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`; export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`;
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`; export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`;
/** 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`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`; export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`; export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`; export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`;

View File

@ -12,9 +12,9 @@ export interface UseToolsPanelReturn {
readonly activeGroups: ToolGroup[]; readonly activeGroups: ToolGroup[];
readonly totalToolCount: number; readonly totalToolCount: number;
readonly noToolsInfoMessage: string | null; readonly noToolsInfoMessage: string | null;
getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean }; isGroupChecked(group: ToolGroup): boolean;
getEnabledToolCount(group: ToolGroup): number; getEnabledToolCount(group: ToolGroup): number;
getFavicon(group: { source: ToolSource; label: string }): string | null; getFavicon(group: ToolGroup): string | null;
isGroupDisabled(group: ToolGroup): boolean; isGroupDisabled(group: ToolGroup): boolean;
toggleGroupExpanded(label: string): void; toggleGroupExpanded(label: string): void;
/** Toggle all tools in a group by label (avoids stale group object references). */ /** Toggle all tools in a group by label (avoids stale group object references). */
@ -54,27 +54,18 @@ export function useToolsPanel(): UseToolsPanelReturn {
return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`; return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`;
}); });
function getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean } { function isGroupChecked(group: ToolGroup): boolean {
return { return toolsStore.isGroupFullyEnabled(group);
checked: toolsStore.isGroupFullyEnabled(group),
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
};
} }
function getEnabledToolCount(group: ToolGroup): number { function getEnabledToolCount(group: ToolGroup): number {
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.function.name)).length; return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.key)).length;
} }
function getFavicon(group: { source: ToolSource; label: string }): string | null { function getFavicon(group: ToolGroup): string | null {
if (group.source !== ToolSource.MCP) return null; if (group.source !== ToolSource.MCP || !group.serverId) return null;
for (const server of mcpStore.getServersSorted()) { return mcpStore.getServerFavicon(group.serverId);
if (mcpStore.getServerLabel(server) === group.label) {
return mcpStore.getServerFavicon(server.id);
}
}
return null;
} }
function isGroupDisabled(group: ToolGroup): boolean { function isGroupDisabled(group: ToolGroup): boolean {
@ -121,7 +112,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
get noToolsInfoMessage() { get noToolsInfoMessage() {
return noToolsInfoMessage; return noToolsInfoMessage;
}, },
getGroupCheckedState, isGroupChecked,
getEnabledToolCount, getEnabledToolCount,
getFavicon, getFavicon,
isGroupDisabled, isGroupDisabled,

View File

@ -4,12 +4,39 @@ import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums'; import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { import {
DISABLED_TOOLS_LOCALSTORAGE_KEY, DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
TOOL_GROUP_LABELS, TOOL_GROUP_LABELS,
TOOL_SERVER_LABELS TOOL_SERVER_LABELS
} from '$lib/constants'; } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/** Stable selection identity for a tool, shared by the disabled set and the permission store */
function toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) {
case ToolSource.MCP:
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
case ToolSource.CUSTOM:
return `custom:${name}`;
default:
return `builtin:${name}`;
}
}
function mcpDefinition(
name: string,
description: string | undefined,
schema?: Record<string, unknown>
): OpenAIToolDefinition {
return {
type: ToolCallType.FUNCTION,
function: {
name,
description,
parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
}
};
}
class ToolsStore { class ToolsStore {
private _builtinTools = $state<OpenAIToolDefinition[]>([]); private _builtinTools = $state<OpenAIToolDefinition[]>([]);
@ -20,12 +47,12 @@ class ToolsStore {
constructor() { constructor() {
try { try {
const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY); const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
if (stored) { if (stored) {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
for (const name of parsed) { for (const key of parsed) {
if (typeof name === 'string') this._disabledTools.add(name); if (typeof key === 'string') this._disabledTools.add(key);
} }
} }
} }
@ -33,14 +60,13 @@ class ToolsStore {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err); console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
} }
// Initialize builtin tools on startup
this.fetchBuiltinTools(); this.fetchBuiltinTools();
} }
private persistDisabledTools(): void { private persistDisabledTools(): void {
try { try {
localStorage.setItem( localStorage.setItem(
DISABLED_TOOLS_LOCALSTORAGE_KEY, DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledTools]) JSON.stringify([...this._disabledTools])
); );
} catch { } catch {
@ -78,167 +104,141 @@ class ToolsStore {
} }
} }
/** Flat list of all tool entries with source metadata */ /** Normalize MCP tools from live connections when available, fall back to health check data */
get allTools(): ToolEntry[] { private mcpEntries(): {
const entries: ToolEntry[] = []; serverId: string;
serverName: string;
definition: OpenAIToolDefinition;
}[] {
const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
for (const def of this._builtinTools) {
entries.push({ source: ToolSource.BUILTIN, definition: def });
}
// Use live connections when available (full schema), fall back to health check data
const connections = mcpStore.getConnections(); const connections = mcpStore.getConnections();
if (connections.size > 0) { if (connections.size > 0) {
for (const [serverId, connection] of connections) { for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId); const serverName = mcpStore.getServerDisplayName(serverId);
for (const tool of connection.tools) { for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? { const schema = (tool.inputSchema as Record<string, unknown>) ?? undefined;
type: JsonSchemaType.OBJECT, out.push({
properties: {},
required: []
};
entries.push({
source: ToolSource.MCP,
serverName,
serverId, serverId,
definition: { serverName,
type: ToolCallType.FUNCTION, definition: mcpDefinition(tool.name, tool.description, schema)
function: {
name: tool.name,
description: tool.description,
parameters: rawSchema
}
}
}); });
} }
} }
} else { } else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) { for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) { for (const tool of tools) {
entries.push({ out.push({
source: ToolSource.MCP,
serverName,
serverId, serverId,
definition: { serverName,
type: ToolCallType.FUNCTION, definition: mcpDefinition(tool.name, tool.description)
function: {
name: tool.name,
description: tool.description,
parameters: {
type: JsonSchemaType.OBJECT,
properties: {},
required: []
}
}
}
}); });
} }
} }
} }
return out;
}
/** Canonical flat list of tool entries with source metadata and stable keys, deduped by key */
get allTools(): ToolEntry[] {
const entries: ToolEntry[] = [];
const seen = new SvelteSet<string>();
const push = (entry: ToolEntry) => {
if (seen.has(entry.key)) return;
seen.add(entry.key);
entries.push(entry);
};
for (const def of this._builtinTools) {
const name = def.function.name;
push({ source: ToolSource.BUILTIN, key: toolKey(ToolSource.BUILTIN, name), definition: def });
}
for (const { serverId, serverName, definition } of this.mcpEntries()) {
const name = definition.function.name;
push({
source: ToolSource.MCP,
serverId,
serverName,
key: toolKey(ToolSource.MCP, name, serverId),
definition
});
}
for (const def of this.customTools) { for (const def of this.customTools) {
entries.push({ source: ToolSource.CUSTOM, definition: def }); const name = def.function.name;
push({ source: ToolSource.CUSTOM, key: toolKey(ToolSource.CUSTOM, name), definition: def });
} }
return entries; return entries;
} }
/** Tools grouped by category for tree display */ /** Tools grouped by category for tree display, derived from the canonical entries */
get toolGroups(): ToolGroup[] { get toolGroups(): ToolGroup[] {
const groups: ToolGroup[] = []; const groups: ToolGroup[] = [];
const byKey = new SvelteMap<string, ToolGroup>();
if (this._builtinTools.length > 0) { for (const entry of this.allTools) {
groups.push({ const groupKey =
source: ToolSource.BUILTIN, entry.source === ToolSource.MCP ? `mcp:${entry.serverId ?? ''}` : entry.source;
label: TOOL_GROUP_LABELS[ToolSource.BUILTIN],
tools: this._builtinTools
});
}
// Use live connections when available, fall back to health check data let group = byKey.get(groupKey);
const connections = mcpStore.getConnections(); if (!group) {
if (connections.size > 0) { group = {
for (const [serverId, connection] of connections) { source: entry.source,
if (connection.tools.length === 0) continue; label: this.groupLabel(entry),
const label = mcpStore.getServerDisplayName(serverId); serverId: entry.serverId,
const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => { tools: []
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? { };
type: JsonSchemaType.OBJECT, byKey.set(groupKey, group);
properties: {}, groups.push(group);
required: []
};
return {
type: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: rawSchema
}
};
});
groups.push({ source: ToolSource.MCP, label, serverId, tools });
} }
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
if (tools.length === 0) continue;
const defs: OpenAIToolDefinition[] = tools.map((tool) => ({
type: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
}
}));
groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs });
}
}
const custom = this.customTools; group.tools.push(entry);
if (custom.length > 0) {
groups.push({
source: ToolSource.CUSTOM,
label: TOOL_GROUP_LABELS[ToolSource.CUSTOM],
tools: custom
});
} }
return groups; return groups;
} }
/** Only enabled tool definitions (for sending to the API) */ private groupLabel(entry: ToolEntry): string {
get enabledToolDefinitions(): OpenAIToolDefinition[] { switch (entry.source) {
return this.allTools case ToolSource.MCP:
.filter((t) => !this._disabledTools.has(t.definition.function.name)) return entry.serverName ?? '';
.map((t) => t.definition); case ToolSource.CUSTOM:
return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
default:
return TOOL_GROUP_LABELS[ToolSource.BUILTIN];
}
} }
/** /**
* Returns enabled tool definitions for sending to the LLM. * Enabled tool definitions for sending to the LLM.
* MCP tools use properly normalized schemas from mcpStore. * MCP tools keep their normalized schemas from mcpStore.
* Filters out tools disabled via the UI checkboxes. * The API identifies tools by name, so a name is sent at most once.
*/ */
getEnabledToolsForLLM(): OpenAIToolDefinition[] { getEnabledToolsForLLM(): OpenAIToolDefinition[] {
const disabled = this._disabledTools; const enabledNames = new SvelteSet<string>();
for (const entry of this.allTools) {
if (!this._disabledTools.has(entry.key)) {
enabledNames.add(entry.definition.function.name);
}
}
const result: OpenAIToolDefinition[] = []; const result: OpenAIToolDefinition[] = [];
const seen = new SvelteSet<string>();
for (const tool of this._builtinTools) { const take = (def: OpenAIToolDefinition) => {
if (!disabled.has(tool.function.name)) { const name = def.function.name;
result.push(tool); if (!enabledNames.has(name) || seen.has(name)) return;
} seen.add(name);
} result.push(def);
};
// MCP tools with properly normalized schemas for (const def of this._builtinTools) take(def);
for (const tool of mcpStore.getToolDefinitionsForLLM()) { for (const def of mcpStore.getToolDefinitionsForLLM()) take(def);
if (!disabled.has(tool.function.name)) { for (const def of this.customTools) take(def);
result.push(tool);
}
}
for (const tool of this.customTools) {
if (!disabled.has(tool.function.name)) {
result.push(tool);
}
}
return result; return result;
} }
@ -263,61 +263,50 @@ class ToolsStore {
return this._disabledTools; return this._disabledTools;
} }
isToolEnabled(toolName: string): boolean { isToolEnabled(key: string): boolean {
return !this._disabledTools.has(toolName); return !this._disabledTools.has(key);
} }
toggleTool(toolName: string): void { toggleTool(key: string): void {
if (this._disabledTools.has(toolName)) { if (this._disabledTools.has(key)) {
this._disabledTools.delete(toolName); this._disabledTools.delete(key);
} else { } else {
this._disabledTools.add(toolName); this._disabledTools.add(key);
} }
this.persistDisabledTools(); this.persistDisabledTools();
} }
setToolEnabled(toolName: string, enabled: boolean): void { setToolEnabled(key: string, enabled: boolean): void {
if (enabled) { if (enabled) {
this._disabledTools.delete(toolName); this._disabledTools.delete(key);
} else { } else {
this._disabledTools.add(toolName); this._disabledTools.add(key);
} }
} }
/** /** Enable all tools belonging to a specific MCP server */
* Enable all tools belonging to a specific MCP server.
* Called when a server is enabled for a conversation.
*/
enableAllToolsForServer(serverId: string): void { enableAllToolsForServer(serverId: string): void {
const connection = mcpStore.getConnections().get(serverId); const connection = mcpStore.getConnections().get(serverId);
if (!connection) return; if (!connection) return;
for (const tool of connection.tools) { for (const tool of connection.tools) {
this._disabledTools.delete(tool.name); this._disabledTools.delete(toolKey(ToolSource.MCP, tool.name, serverId));
} }
this.persistDisabledTools(); this.persistDisabledTools();
} }
toggleGroup(group: ToolGroup): void { toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name)); const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
for (const tool of group.tools) { for (const tool of group.tools) {
this.setToolEnabled(tool.function.name, !allEnabled); this.setToolEnabled(tool.key, !allEnabled);
} }
this.persistDisabledTools(); this.persistDisabledTools();
} }
isGroupFullyEnabled(group: ToolGroup): boolean { isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name)); return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
} }
isGroupPartiallyEnabled(group: ToolGroup): boolean { /** Get MCP tools from health check data, used when live connections aren't established yet */
const enabledCount = group.tools.filter((t) => this.isToolEnabled(t.function.name)).length;
return enabledCount > 0 && enabledCount < group.tools.length;
}
/**
* Get MCP tools from health check data (reactive).
* Used when live connections aren't established yet.
*/
private getMcpToolsFromHealthChecks(): { private getMcpToolsFromHealthChecks(): {
serverId: string; serverId: string;
serverName: string; serverName: string;
@ -337,60 +326,35 @@ class ToolsStore {
return result; return result;
} }
/** Determine the source of a tool by its name. */ /** First canonical entry matching a tool name, runtime tool calls resolve by name */
getToolSource(toolName: string): ToolSource | null { private findEntryByName(toolName: string): ToolEntry | null {
if (this._builtinTools.some((t) => t.function.name === toolName)) {
return ToolSource.BUILTIN;
}
for (const entry of this.allTools) { for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) { if (entry.definition.function.name === toolName) return entry;
return entry.source;
}
} }
return null; return null;
} }
/** Get the display label for the server that owns a given tool. */ /** Determine the source of a tool by its name */
getToolSource(toolName: string): ToolSource | null {
return this.findEntryByName(toolName)?.source ?? null;
}
/** Get the display label for the server that owns a given tool */
getToolServerLabel(toolName: string): string { getToolServerLabel(toolName: string): string {
for (const entry of this.allTools) { const entry = this.findEntryByName(toolName);
if (entry.definition.function.name === toolName) { if (!entry) return '';
if (entry.serverName) { if (entry.serverName) return mcpStore.getServerDisplayName(entry.serverName);
return mcpStore.getServerDisplayName(entry.serverName); if (entry.source === ToolSource.BUILTIN) return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
} if (entry.source === ToolSource.CUSTOM) return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
if (entry.source === ToolSource.BUILTIN) {
return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
}
if (entry.source === ToolSource.CUSTOM) {
return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
}
}
}
return ''; return '';
} }
/** Build a permission key with category prefix, e.g. "mcp-<serverId>:tool_name" */ /** Permission key for a tool name, identical to the selection key */
getPermissionKey(toolName: string): string | null { getPermissionKey(toolName: string): string | null {
for (const entry of this.allTools) { return this.findEntryByName(toolName)?.key ?? null;
if (entry.definition.function.name === toolName) {
switch (entry.source) {
case ToolSource.BUILTIN:
return `builtin:${toolName}`;
case ToolSource.CUSTOM:
return `custom:${toolName}`;
case ToolSource.MCP:
if (entry.serverId) {
return `mcp-${entry.serverId}:${toolName}`;
}
return `mcp:${toolName}`;
default:
return null;
}
}
}
return null;
} }
/** Check if there are any enabled tools available (builtin, MCP, or custom). */ /** Check if there are any enabled tools available (builtin, MCP, or custom) */
get hasEnabledTools(): boolean { get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0; return this.getEnabledToolsForLLM().length > 0;
} }
@ -423,5 +387,4 @@ export const toolsStore = new ToolsStore();
export const allTools = () => toolsStore.allTools; export const allTools = () => toolsStore.allTools;
export const allToolDefinitions = () => toolsStore.allToolDefinitions; export const allToolDefinitions = () => toolsStore.allToolDefinitions;
export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions;
export const toolGroups = () => toolsStore.toolGroups; export const toolGroups = () => toolsStore.toolGroups;

View File

@ -7,6 +7,8 @@ export interface ToolEntry {
serverName?: string; serverName?: string;
/** For MCP tools, the server ID (used for permission keys) */ /** For MCP tools, the server ID (used for permission keys) */
serverId?: string; serverId?: string;
/** Stable selection identity: builtin:name, mcp-<serverId>:name, mcp:name, custom:name */
key: string;
definition: OpenAIToolDefinition; definition: OpenAIToolDefinition;
} }
@ -15,5 +17,5 @@ export interface ToolGroup {
label: string; label: string;
/** For MCP groups, the server ID */ /** For MCP groups, the server ID */
serverId?: string; serverId?: string;
tools: OpenAIToolDefinition[]; tools: ToolEntry[];
} }