feat: Builtin + MCP + JSON Schema Tools WIP

This commit is contained in:
Aleksander Grygier 2026-03-19 18:24:43 +01:00
parent 684ed10a04
commit 155af69edf
10 changed files with 627 additions and 33 deletions

Binary file not shown.

View File

@ -1,7 +1,19 @@
<script lang="ts">
import { page } from '$app/state';
import { Plus, MessageSquare, Settings, Zap, FolderOpen } from '@lucide/svelte';
import {
Plus,
MessageSquare,
Settings,
Zap,
FolderOpen,
PencilRuler,
ChevronDown,
ChevronRight,
Loader2
} from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Switch } from '$lib/components/ui/switch';
@ -9,9 +21,10 @@
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { toolsStore, ToolSource } from '$lib/stores/tools.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
class?: string;
@ -51,21 +64,67 @@
let dropdownOpen = $state(false);
let expandedGroups = new SvelteSet<string>();
let groups = $derived(
toolsStore.toolGroups.filter(
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
)
);
let totalToolCount = $derived(groups.reduce((n, g) => n + g.tools.length, 0));
let enabledToolCount = $derived(
groups.reduce(
(n, g) => n + g.tools.filter((t) => toolsStore.isToolEnabled(t.function.name)).length,
0
)
);
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
let mcpSearchQuery = $state('');
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const name = mcpStore.getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
function getGroupCheckedState(group: (typeof groups)[number]): {
checked: boolean;
indeterminate: boolean;
} {
return {
checked: toolsStore.isGroupFullyEnabled(group),
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
};
}
function getFavicon(group: { source: ToolSource; label: string }): string | null {
if (group.source !== ToolSource.MCP) return null;
for (const server of mcpStore.getServersSorted()) {
if (mcpStore.getServerLabel(server) === group.label) {
return mcpStore.getServerFavicon(server.id);
}
}
return null;
}
function handleToolsSubMenuOpen(open: boolean) {
if (open) {
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
}
mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
}
}
function isServerEnabledForChat(serverId: string): boolean {
@ -97,8 +156,6 @@
dropdownOpen = false;
onMcpResourcesClick?.();
}
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
</script>
<div class="flex items-center gap-1 {className}">
@ -234,6 +291,112 @@
<DropdownMenu.Separator />
<DropdownMenu.Sub onOpenChange={handleToolsSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<PencilRuler class="h-4 w-4" />
<span>Tools</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 p-0">
{#if totalToolCount === 0}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
{#if toolsStore.loading}
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
Loading tools...
{:else if toolsStore.error}
Failed to load tools
{:else}
No tools available
{/if}
</div>
{:else}
<div class="px-3 py-2 text-xs font-medium text-muted-foreground">
{enabledToolCount}/{totalToolCount} tools enabled
</div>
<div class="max-h-80 overflow-y-auto px-1 pb-2">
{#each groups as group (group.label)}
{@const isExpanded = expandedGroups.has(group.label)}
{@const { checked, indeterminate } = getGroupCheckedState(group)}
{@const favicon = getFavicon(group)}
<Collapsible.Root
open={isExpanded}
onOpenChange={() => {
if (expandedGroups.has(group.label)) {
expandedGroups.delete(group.label);
} else {
expandedGroups.add(group.label);
}
}}
>
<div class="flex items-center gap-1">
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{group.tools.length}
</span>
</Collapsible.Trigger>
<Checkbox
{checked}
{indeterminate}
onCheckedChange={() => toolsStore.toggleGroup(group)}
class="mr-2 h-4 w-4 shrink-0"
/>
</div>
<Collapsible.Content>
<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)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-muted/50"
onclick={() => toolsStore.toggleTool(tool.function.name)}
>
<Checkbox
checked={toolsStore.isToolEnabled(tool.function.name)}
onCheckedChange={() => toolsStore.toggleTool(tool.function.name)}
class="h-4 w-4 shrink-0"
/>
<span class="min-w-0 flex-1 truncate">
{tool.function.name}
</span>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class="h-4 w-4" />
@ -272,7 +435,7 @@
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
<span class="truncate text-sm">{mcpStore.getServerLabel(server)}</span>
{#if hasError}
<span

View File

@ -13,7 +13,7 @@
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
'z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
className
)}
{...restProps}

View File

@ -5,7 +5,8 @@ export const API_MODELS = {
};
export const API_TOOLS = {
LIST: '/tools'
LIST: '/tools',
EXECUTE: '/tools'
};
/** CORS proxy endpoint path */

View File

@ -2,3 +2,4 @@ export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
export const FAVOURITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favouriteModels';
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = 'LlamaCppWebui.mcpDefaultEnabled';
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = 'LlamaCppWebui.disabledTools';

View File

@ -424,9 +424,10 @@ export class ChatService {
try {
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content;
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
const toolCalls = parsed.choices[0]?.delta?.tool_calls;
const choice = parsed.choices?.[0];
const content = choice?.delta?.content;
const reasoningContent = choice?.delta?.reasoning_content;
const toolCalls = choice?.delta?.tool_calls;
const timings = parsed.timings;
const promptProgress = parsed.prompt_progress;

View File

@ -1,6 +1,6 @@
import { apiFetch } from '$lib/utils';
import { API_TOOLS } from '$lib/constants';
import type { OpenAIToolDefinition } from '$lib/types';
import type { OpenAIToolDefinition, ToolExecutionResult } from '$lib/types';
export class ToolsService {
/**
@ -11,4 +11,22 @@ export class ToolsService {
static async list(): Promise<OpenAIToolDefinition[]> {
return apiFetch<OpenAIToolDefinition[]>(API_TOOLS.LIST);
}
/**
* Execute a built-in tool on the server.
*/
static async executeTool(
toolName: string,
params: Record<string, unknown>,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
const result = await apiFetch<Record<string, unknown>>(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 };
}
}

View File

@ -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<string, unknown>): Record<string, unknown> {
if (typeof args === 'object') return args;
const trimmed = args.trim();
if (trimmed === '') return {};
return JSON.parse(trimmed) as Record<string, unknown>;
}
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
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?.(

View File

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

View File

@ -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<OpenAIToolDefinition[]>([]);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
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<string, unknown>) ?? {
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<string, unknown>) ?? {
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<string> {
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<ToolsStore['getMcpToolsFromHealthChecks']> = [];
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<void> {
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;