diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index f1ccf5a755..88670253fb 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte index b9bb5b7e3f..db0bd18fa0 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte @@ -1,7 +1,19 @@
@@ -234,6 +291,112 @@ + + + + + Tools + + + + {#if totalToolCount === 0} +
+ {#if toolsStore.loading} + + Loading tools... + {:else if toolsStore.error} + Failed to load tools + {:else} + No tools available + {/if} +
+ {:else} +
+ {enabledToolCount}/{totalToolCount} tools enabled +
+ +
+ {#each groups as group (group.label)} + {@const isExpanded = expandedGroups.has(group.label)} + {@const { checked, indeterminate } = getGroupCheckedState(group)} + {@const favicon = getFavicon(group)} + + { + if (expandedGroups.has(group.label)) { + expandedGroups.delete(group.label); + } else { + expandedGroups.add(group.label); + } + }} + > +
+ + {#if isExpanded} + + {:else} + + {/if} + + + {#if favicon} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} + + {group.label} + + + + {group.tools.length} + + + + toolsStore.toggleGroup(group)} + class="mr-2 h-4 w-4 shrink-0" + /> +
+ + +
+ {#each group.tools as tool (tool.function.name)} + + {/each} +
+
+
+ {/each} +
+ {/if} +
+
+ @@ -272,7 +435,7 @@ /> {/if} - {getServerLabel(server)} + {mcpStore.getServerLabel(server)} {#if hasError} { return apiFetch(API_TOOLS.LIST); } + + /** + * Execute a built-in tool on the server. + */ + static async executeTool( + toolName: string, + params: Record, + signal?: AbortSignal + ): Promise { + const result = await apiFetch>(API_TOOLS.EXECUTE, { + method: 'POST', + body: JSON.stringify({ tool: toolName, params }), + signal + }); + const isError = 'error' in result; + const content = isError ? String(result.error) : JSON.stringify(result); + return { content, isError }; + } } diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts index f8834f9df3..41d8527678 100644 --- a/tools/server/webui/src/lib/stores/agentic.svelte.ts +++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts @@ -21,6 +21,8 @@ import { ChatService } from '$lib/services'; import { config } from '$lib/stores/settings.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte'; import { modelsStore } from '$lib/stores/models.svelte'; +import { toolsStore, ToolSource } from '$lib/stores/tools.svelte'; +import { ToolsService } from '$lib/services/tools.service'; import { isAbortError } from '$lib/utils'; import { DEFAULT_AGENTIC_CONFIG, @@ -184,13 +186,24 @@ class AgenticStore { const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns; const maxToolPreviewLines = Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines; + const hasTools = + mcpStore.hasEnabledServers(perChatOverrides) || + toolsStore.builtinTools.length > 0 || + toolsStore.customTools.length > 0; return { - enabled: mcpStore.hasEnabledServers(perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled, + enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled, maxTurns, maxToolPreviewLines }; } + private parseToolArguments(args: string | Record): Record { + if (typeof args === 'object') return args; + const trimmed = args.trim(); + if (trimmed === '') return {}; + return JSON.parse(trimmed) as Record; + } + async runAgenticFlow(params: AgenticFlowParams): Promise { const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params; const { @@ -208,15 +221,24 @@ class AgenticStore { const agenticConfig = this.getConfig(config(), perChatOverrides); if (!agenticConfig.enabled) return { handled: false }; - const initialized = await mcpStore.ensureInitialized(perChatOverrides); - if (!initialized) { - console.log('[AgenticStore] MCP not initialized, falling back to standard chat'); - return { handled: false }; + const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides); + if (hasMcpServers) { + const initialized = await mcpStore.ensureInitialized(perChatOverrides); + + if (!initialized) { + console.log('[AgenticStore] MCP not initialized'); + } } - const tools = mcpStore.getToolDefinitionsForLLM(); + // Ensure built-in tools are fetched + if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) { + await toolsStore.fetchBuiltinTools(); + } + + const tools = toolsStore.getEnabledToolsForLLM(); if (tools.length === 0) { console.log('[AgenticStore] No tools available, falling back to standard chat'); + return { handled: false }; } @@ -244,7 +266,8 @@ class AgenticStore { totalToolCalls: 0, lastError: null }); - mcpStore.acquireConnection(); + + if (hasMcpServers) mcpStore.acquireConnection(); try { await this.executeAgenticLoop({ @@ -274,11 +297,14 @@ class AgenticStore { return { handled: true, error: normalizedError }; } finally { this.updateSession(conversationId, { isRunning: false }); - await mcpStore - .releaseConnection() - .catch((err: unknown) => - console.warn('[AgenticStore] Failed to release MCP connection:', err) - ); + + if (hasMcpServers) { + await mcpStore + .releaseConnection() + .catch((err: unknown) => + console.warn('[AgenticStore] Failed to release MCP connection:', err) + ); + } } } @@ -517,17 +543,29 @@ class AgenticStore { } const toolStartTime = performance.now(); - const mcpCall: MCPToolCall = { - id: toolCall.id, - function: { name: toolCall.function.name, arguments: toolCall.function.arguments } - }; + const toolName = toolCall.function.name; + const toolSource = toolsStore.getToolSource(toolName); let result: string; let toolSuccess = true; try { - const executionResult = await mcpStore.executeTool(mcpCall, signal); - result = executionResult.content; + if (toolSource === ToolSource.BUILTIN) { + const args = this.parseToolArguments(toolCall.function.arguments); + const executionResult = await ToolsService.executeTool(toolName, args, signal); + + result = executionResult.content; + + if (executionResult.isError) toolSuccess = false; + } else { + const mcpCall: MCPToolCall = { + id: toolCall.id, + function: { name: toolName, arguments: toolCall.function.arguments } + }; + const executionResult = await mcpStore.executeTool(mcpCall, signal); + + result = executionResult.content; + } } catch (error) { if (isAbortError(error)) { onComplete?.( diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index c31dfc8cbf..54563112fb 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -17,6 +17,7 @@ import { conversationsStore } from '$lib/stores/conversations.svelte'; import { config } from '$lib/stores/settings.svelte'; import { agenticStore } from '$lib/stores/agentic.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte'; +import { toolsStore } from '$lib/stores/tools.svelte'; import { contextSize, isRouterMode } from '$lib/stores/server.svelte'; import { selectedModelName, @@ -731,10 +732,12 @@ class ChatStore { if (agenticResult.handled) return; } + const enabledTools = toolsStore.getEnabledToolsForLLM(); const completionOptions = { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}), - ...streamCallbacks + ...streamCallbacks, + ...(enabledTools.length > 0 ? { tools: enabledTools } : {}) }; await ChatService.sendMessage( diff --git a/tools/server/webui/src/lib/stores/tools.svelte.ts b/tools/server/webui/src/lib/stores/tools.svelte.ts new file mode 100644 index 0000000000..5d19d605aa --- /dev/null +++ b/tools/server/webui/src/lib/stores/tools.svelte.ts @@ -0,0 +1,369 @@ +import type { OpenAIToolDefinition } from '$lib/types'; +import { ToolsService } from '$lib/services/tools.service'; +import { mcpStore } from '$lib/stores/mcp.svelte'; +import { HealthCheckStatus } from '$lib/enums'; +import { config } from '$lib/stores/settings.svelte'; +import { DISABLED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants'; +import { SvelteSet } from 'svelte/reactivity'; + +export enum ToolSource { + BUILTIN = 'builtin', + MCP = 'mcp', + CUSTOM = 'custom' +} + +export interface ToolEntry { + source: ToolSource; + /** For MCP tools, the server ID; otherwise undefined */ + serverName?: string; + definition: OpenAIToolDefinition; +} + +export interface ToolGroup { + source: ToolSource; + label: string; + /** For MCP groups, the server ID */ + serverId?: string; + tools: OpenAIToolDefinition[]; +} + +class ToolsStore { + private _builtinTools = $state([]); + private _loading = $state(false); + private _error = $state(null); + private _disabledTools = $state(new SvelteSet()); + + constructor() { + try { + const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + for (const name of parsed) { + if (typeof name === 'string') this._disabledTools.add(name); + } + } + } + } catch { + throw new Error('Failed to load disabled tools from localStorage'); + } + } + + private persistDisabledTools(): void { + try { + localStorage.setItem( + DISABLED_TOOLS_LOCALSTORAGE_KEY, + JSON.stringify([...this._disabledTools]) + ); + } catch { + // ignore storage errors + } + } + + get builtinTools(): OpenAIToolDefinition[] { + return this._builtinTools; + } + + get mcpTools(): OpenAIToolDefinition[] { + return mcpStore.getToolDefinitionsForLLM(); + } + + get customTools(): OpenAIToolDefinition[] { + const raw = config().custom; + if (!raw || typeof raw !== 'string') return []; + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + + return parsed.filter( + (t: unknown): t is OpenAIToolDefinition => + typeof t === 'object' && + t !== null && + 'type' in t && + (t as OpenAIToolDefinition).type === 'function' && + 'function' in t && + typeof (t as OpenAIToolDefinition).function?.name === 'string' + ); + } catch { + return []; + } + } + + /** Flat list of all tool entries with source metadata */ + get allTools(): ToolEntry[] { + const entries: ToolEntry[] = []; + + 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(); + if (connections.size > 0) { + for (const [serverId, connection] of connections) { + const serverName = mcpStore.getServerDisplayName(serverId); + for (const tool of connection.tools) { + const rawSchema = (tool.inputSchema as Record) ?? { + type: 'object', + properties: {}, + required: [] + }; + entries.push({ + source: ToolSource.MCP, + serverName, + definition: { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: rawSchema + } + } + }); + } + } + } else { + for (const { serverName, tools } of this.getMcpToolsFromHealthChecks()) { + for (const tool of tools) { + entries.push({ + source: ToolSource.MCP, + serverName, + definition: { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: { type: 'object', properties: {}, required: [] } + } + } + }); + } + } + } + + for (const def of this.customTools) { + entries.push({ source: ToolSource.CUSTOM, definition: def }); + } + + return entries; + } + + /** Tools grouped by category for tree display */ + get toolGroups(): ToolGroup[] { + const groups: ToolGroup[] = []; + + if (this._builtinTools.length > 0) { + groups.push({ + source: ToolSource.BUILTIN, + label: 'Built-in', + tools: this._builtinTools + }); + } + + // Use live connections when available, fall back to health check data + const connections = mcpStore.getConnections(); + if (connections.size > 0) { + for (const [serverId, connection] of connections) { + if (connection.tools.length === 0) continue; + const label = mcpStore.getServerDisplayName(serverId); + const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => { + const rawSchema = (tool.inputSchema as Record) ?? { + type: 'object', + properties: {}, + required: [] + }; + return { + type: 'function' as const, + 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: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: { type: 'object', properties: {}, required: [] } + } + })); + groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs }); + } + } + + const custom = this.customTools; + if (custom.length > 0) { + groups.push({ + source: ToolSource.CUSTOM, + label: 'JSON Schema', + tools: custom + }); + } + + return groups; + } + + /** Only enabled tool definitions (for sending to the API) */ + get enabledToolDefinitions(): OpenAIToolDefinition[] { + return this.allTools + .filter((t) => !this._disabledTools.has(t.definition.function.name)) + .map((t) => t.definition); + } + + /** + * Returns enabled tool definitions for sending to the LLM. + * MCP tools use properly normalized schemas from mcpStore. + * Filters out tools disabled via the UI checkboxes. + */ + getEnabledToolsForLLM(): OpenAIToolDefinition[] { + const disabled = this._disabledTools; + const result: OpenAIToolDefinition[] = []; + + for (const tool of this._builtinTools) { + if (!disabled.has(tool.function.name)) { + result.push(tool); + } + } + + // MCP tools with properly normalized schemas + for (const tool of mcpStore.getToolDefinitionsForLLM()) { + if (!disabled.has(tool.function.name)) { + result.push(tool); + } + } + + for (const tool of this.customTools) { + if (!disabled.has(tool.function.name)) { + result.push(tool); + } + } + + return result; + } + + get allToolDefinitions(): OpenAIToolDefinition[] { + return this.allTools.map((t) => t.definition); + } + + get loading(): boolean { + return this._loading; + } + + get error(): string | null { + return this._error; + } + + get disabledTools(): SvelteSet { + return this._disabledTools; + } + + isToolEnabled(toolName: string): boolean { + return !this._disabledTools.has(toolName); + } + + toggleTool(toolName: string): void { + if (this._disabledTools.has(toolName)) { + this._disabledTools.delete(toolName); + } else { + this._disabledTools.add(toolName); + } + this.persistDisabledTools(); + } + + setToolEnabled(toolName: string, enabled: boolean): void { + if (enabled) { + this._disabledTools.delete(toolName); + } else { + this._disabledTools.add(toolName); + } + } + + toggleGroup(group: ToolGroup): void { + const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name)); + for (const tool of group.tools) { + this.setToolEnabled(tool.function.name, !allEnabled); + } + this.persistDisabledTools(); + } + + isGroupFullyEnabled(group: ToolGroup): boolean { + return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name)); + } + + isGroupPartiallyEnabled(group: ToolGroup): boolean { + 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(): { + serverId: string; + serverName: string; + tools: { name: string; description?: string }[]; + }[] { + const result: ReturnType = []; + for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) { + const health = mcpStore.getHealthCheckState(server.id); + if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) { + result.push({ + serverId: server.id, + serverName: mcpStore.getServerLabel(server), + tools: health.tools + }); + } + } + return result; + } + + /** Determine the source of a tool by its name. */ + getToolSource(toolName: string): ToolSource | null { + if (this._builtinTools.some((t) => t.function.name === toolName)) { + return ToolSource.BUILTIN; + } + for (const entry of this.allTools) { + if (entry.definition.function.name === toolName) { + return entry.source; + } + } + return null; + } + + /** Check if there are any enabled tools available (builtin, MCP, or custom). */ + get hasEnabledTools(): boolean { + return this.getEnabledToolsForLLM().length > 0; + } + + async fetchBuiltinTools(): Promise { + if (this._loading) return; + + this._loading = true; + this._error = null; + + try { + this._builtinTools = await ToolsService.list(); + } catch (err) { + this._error = err instanceof Error ? err.message : String(err); + console.error('[ToolsStore] Failed to fetch built-in tools:', err); + } finally { + this._loading = false; + } + } +} + +export const toolsStore = new ToolsStore(); + +export const allTools = () => toolsStore.allTools; +export const allToolDefinitions = () => toolsStore.allToolDefinitions; +export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions; +export const toolGroups = () => toolsStore.toolGroups;