diff --git a/tools/server/webui/docs/example_mcp/dockers/searxng/docker-compose.yml b/tools/server/webui/docs/example_mcp/dockers/searxng/docker-compose.yml
new file mode 100644
index 0000000000..e54a434b71
--- /dev/null
+++ b/tools/server/webui/docs/example_mcp/dockers/searxng/docker-compose.yml
@@ -0,0 +1,23 @@
+services:
+ searxng:
+ container_name: searxng
+ image: searxng/searxng:latest
+ ports:
+ - "8181:8080"
+ volumes:
+ - ./searxng:/etc/searxng:rw
+ env_file:
+ - .env
+ restart: unless-stopped
+ cap_drop:
+ - ALL
+ cap_add:
+ - CHOWN
+ - SETGID
+ - SETUID
+ - DAC_OVERRIDE
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "1m"
+ max-file: "1"
\ No newline at end of file
diff --git a/tools/server/webui/docs/example_mcp/dockers/selenium_grid/docker-compose.yml b/tools/server/webui/docs/example_mcp/dockers/selenium_grid/docker-compose.yml
new file mode 100644
index 0000000000..4c18f512c7
--- /dev/null
+++ b/tools/server/webui/docs/example_mcp/dockers/selenium_grid/docker-compose.yml
@@ -0,0 +1,41 @@
+# To execute this docker compose yml file use `docker compose -f docker-compose-v3-node-all-browsers.yml up`
+# Add the `-d` flag at the end for detached execution
+# To stop the execution, hit Ctrl+C, and then `docker compose -f docker-compose-v3-node-all-browsers.yml down`
+services:
+ selenium-hub:
+ image: selenium/hub:4.36.0-20251001
+ container_name: selenium-hub
+ ports:
+ - "4442:4442"
+ - "4443:4443"
+ - "4444:4444"
+
+ all-browsers:
+ image: selenium/node-all-browsers:4.36.0-20251001
+ shm_size: 3gb
+ depends_on:
+ - selenium-hub
+ environment:
+ - SE_EVENT_BUS_HOST=selenium-hub
+ # Uncomment the following lines to set a value to environment variable of particular browser when GENERATE_CONFIG=true
+# - SE_NODE_STEREOTYPE_CHROME=
+# - SE_NODE_BROWSER_NAME_CHROME=
+# - SE_NODE_BROWSER_VERSION_CHROME=
+# - SE_NODE_PLATFORM_NAME_CHROME=
+# - SE_BROWSER_BINARY_LOCATION_CHROME=
+# - SE_NODE_STEREOTYPE_EXTRA_CHROME=
+# - SE_NODE_MAX_SESSIONS_CHROME=
+# - SE_NODE_STEREOTYPE_EDGE=
+# - SE_NODE_BROWSER_NAME_EDGE=
+# - SE_NODE_BROWSER_VERSION_EDGE=
+# - SE_NODE_PLATFORM_NAME_EDGE=
+# - SE_BROWSER_BINARY_LOCATION_EDGE=
+# - SE_NODE_STEREOTYPE_EXTRA_EDGE=
+# - SE_NODE_MAX_SESSIONS_EDGE=
+# - SE_NODE_STEREOTYPE_FIREFOX=
+# - SE_NODE_BROWSER_NAME_FIREFOX=
+# - SE_NODE_BROWSER_VERSION_FIREFOX=
+# - SE_NODE_PLATFORM_NAME_FIREFOX=
+# - SE_BROWSER_BINARY_LOCATION_FIREFOX=
+# - SE_NODE_STEREOTYPE_EXTRA_FIREFOX=
+# - SE_NODE_MAX_SESSIONS_FIREFOX=
diff --git a/tools/server/webui/docs/example_mcp/web_tools_mcp.py b/tools/server/webui/docs/example_mcp/web_tools_mcp.py
new file mode 100644
index 0000000000..7221ed5e08
--- /dev/null
+++ b/tools/server/webui/docs/example_mcp/web_tools_mcp.py
@@ -0,0 +1,344 @@
+#!/usr/bin/env python3
+"""
+Web Tools MCP Server
+
+Provides web search and web scraping capabilities via MCP protocol.
+
+Tools:
+- search_web: Search using SearxNG at localhost:8181
+- scrape_website: Scrape websites using Selenium Grid at localhost:4444
+
+Copyright 2025
+"""
+
+import sys
+import logging
+import asyncio
+import httpx
+from typing import Optional
+
+# Redirect stdout to stderr before MCP imports (MCP uses stdout for protocol)
+original_stdout = sys.stdout
+sys.stdout = sys.stderr
+
+from mcp.server import Server
+from mcp.types import Tool, TextContent
+from mcp import ServerSession, StdioServerParameters
+import mcp.server.stdio
+
+# Configure logging to stderr
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s [%(levelname)s] [%(name)s] %(message)s',
+ handlers=[logging.StreamHandler(sys.stderr)]
+)
+logger = logging.getLogger("web_tools_mcp")
+
+# Restore stdout for MCP protocol
+sys.stdout = original_stdout
+
+# Initialize MCP server
+app = Server("web-tools")
+
+
+# ============================================================================
+# TOOLS
+# ============================================================================
+
+@app.list_tools()
+async def list_tools() -> list[Tool]:
+ """List available web tools"""
+ return [
+ Tool(
+ name="search_web",
+ description=(
+ "Search the web for current information using SearxNG.\n\n"
+ "Use this to:\n"
+ "- Find current information and facts\n"
+ "- Research topics\n"
+ "- Stay up-to-date with recent events\n"
+ "- Gather information not in your training data\n\n"
+ "Requires: SearxNG running at localhost:8181"
+ ),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search query string"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum number of results (default: 5)",
+ "default": 5
+ }
+ },
+ "required": ["query"]
+ }
+ ),
+ Tool(
+ name="scrape_website",
+ description=(
+ "Scrape a website to extract content using Selenium Grid.\n\n"
+ "Use this to:\n"
+ "- Extract information from specific websites\n"
+ "- Gather detailed content from web pages\n"
+ "- Analyze web content\n"
+ "- Access dynamic web pages\n\n"
+ "Requires: Selenium Grid running at localhost:4444"
+ ),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "Website URL to scrape (must include http:// or https://)"
+ },
+ "extract_text": {
+ "type": "boolean",
+ "description": "Whether to extract text content (default: true)",
+ "default": True
+ },
+ "extract_links": {
+ "type": "boolean",
+ "description": "Whether to extract links from the page (default: false)",
+ "default": False
+ }
+ },
+ "required": ["url"]
+ }
+ )
+ ]
+
+
+@app.call_tool()
+async def call_tool(name: str, arguments: dict) -> list[TextContent]:
+ """Handle tool calls"""
+
+ if name == "search_web":
+ query = arguments["query"]
+ limit = arguments.get("limit", 5)
+ logger.info(f"search_web: query='{query}' limit={limit}")
+ result = await _search_web(query=query, limit=limit)
+ return [TextContent(type="text", text=result)]
+
+ elif name == "scrape_website":
+ url = arguments["url"]
+ logger.info(f"scrape_website: url='{url}'")
+ result = await _scrape_website(
+ url=url,
+ extract_text=arguments.get("extract_text", True),
+ extract_links=arguments.get("extract_links", False)
+ )
+ return [TextContent(type="text", text=result)]
+
+ else:
+ raise ValueError(f"Unknown tool: {name}")
+
+
+# ============================================================================
+# TOOL IMPLEMENTATIONS
+# ============================================================================
+
+async def _search_web(query: str, limit: int = 5) -> str:
+ """
+ Search the web using SearxNG
+
+ Args:
+ query: Search query string
+ limit: Maximum number of results
+
+ Returns:
+ Formatted search results or error message
+ """
+ try:
+ url = "http://localhost:8181/search"
+ params = {
+ "q": query,
+ "format": "json",
+ "language": "en",
+ "categories": "general",
+ "safesearch": 1,
+ "count": limit
+ }
+
+ async with httpx.AsyncClient(timeout=10) as client:
+ response = await client.get(url, params=params)
+ response.raise_for_status()
+
+ data = response.json()
+ results = data.get("results", [])[:limit]
+
+ if not results:
+ return f"No results found for: {query}"
+
+ output = f"Search results for '{query}':\n\n"
+ for i, r in enumerate(results, 1):
+ output += f"{i}. {r.get('title', 'No title')}\n"
+ output += f" URL: {r.get('url', 'No URL')}\n"
+ snippet = r.get('content', '')
+ if snippet:
+ output += f" {snippet[:200]}...\n"
+ output += "\n"
+
+ return output
+
+ except httpx.ConnectError:
+ logger.error("Could not connect to SearxNG at localhost:8181")
+ return "Error: SearxNG is not available. Make sure it's running at localhost:8181"
+ except Exception as e:
+ logger.error(f"search_web failed: {e}")
+ return f"Error searching web: {str(e)}"
+
+
+async def _scrape_website(
+ url: str,
+ extract_text: bool = True,
+ extract_links: bool = False
+) -> str:
+ """
+ Scrape a website using Selenium Grid
+
+ Args:
+ url: Website URL to scrape
+ extract_text: Whether to extract text content
+ extract_links: Whether to extract links
+
+ Returns:
+ Scraped content or error message
+ """
+ if not url.startswith(('http://', 'https://')):
+ return "Error: Invalid URL. Must start with http:// or https://"
+
+ try:
+ # Check Selenium Grid availability
+ grid_url = "http://localhost:4444/wd/hub"
+ async with httpx.AsyncClient(timeout=10) as client:
+ try:
+ status_resp = await client.get(f"{grid_url}/status")
+ if status_resp.status_code != 200:
+ return "Error: Selenium Grid is not available at localhost:4444"
+ except:
+ return "Error: Selenium Grid is not available at localhost:4444"
+
+ # Create session
+ capabilities = {
+ "capabilities": {
+ "alwaysMatch": {
+ "browserName": "chrome",
+ "platformName": "ANY",
+ "goog:chromeOptions": {
+ "args": ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--headless"]
+ }
+ }
+ }
+ }
+
+ session_resp = await client.post(
+ f"{grid_url}/session",
+ json=capabilities,
+ headers={'Content-Type': 'application/json'},
+ timeout=60
+ )
+
+ if session_resp.status_code != 200:
+ return f"Error: Failed to create browser session"
+
+ session_data = session_resp.json()
+ session_id = session_data['value']['sessionId']
+
+ try:
+ # Navigate to URL
+ await client.post(
+ f"{grid_url}/session/{session_id}/url",
+ json={"url": url},
+ headers={'Content-Type': 'application/json'},
+ timeout=60
+ )
+
+ # Wait a moment for page to load
+ await asyncio.sleep(2)
+
+ output = f"Scraped content from: {url}\n\n"
+
+ # Extract text if requested
+ if extract_text:
+ script = "return document.body.innerText || document.body.textContent || '';"
+ text_resp = await client.post(
+ f"{grid_url}/session/{session_id}/execute/sync",
+ json={"script": script, "args": []},
+ headers={'Content-Type': 'application/json'},
+ timeout=60
+ )
+
+ if text_resp.status_code == 200:
+ text_data = text_resp.json()
+ text = text_data['value']
+ # Truncate if too long
+ if len(text) > 5000:
+ text = text[:5000] + "... [truncated]"
+ output += f"Text Content:\n{text}\n\n"
+
+ # Extract links if requested
+ if extract_links:
+ link_script = """
+ const links = Array.from(document.querySelectorAll('a[href]'));
+ return links.map(link => ({
+ text: link.innerText || link.textContent || '',
+ href: link.href
+ })).filter(link => link.href && link.href.startsWith('http')).slice(0, 20);
+ """
+ link_resp = await client.post(
+ f"{grid_url}/session/{session_id}/execute/sync",
+ json={"script": link_script, "args": []},
+ headers={'Content-Type': 'application/json'},
+ timeout=60
+ )
+
+ if link_resp.status_code == 200:
+ link_data = link_resp.json()
+ links = link_data['value']
+ output += f"Links Found ({len(links)}):\n"
+ for i, link in enumerate(links[:10], 1):
+ output += f"{i}. {link.get('text', 'No text')[:50]} - {link.get('href')}\n"
+
+ return output
+
+ finally:
+ # Clean up session
+ try:
+ await client.delete(f"{grid_url}/session/{session_id}", timeout=10)
+ except:
+ pass
+
+ except httpx.ConnectError:
+ logger.error("Could not connect to Selenium Grid at localhost:4444")
+ return "Error: Selenium Grid is not available at localhost:4444"
+ except Exception as e:
+ logger.error(f"scrape_website failed: {e}")
+ return f"Error scraping website: {str(e)}"
+
+
+# ============================================================================
+# MAIN
+# ============================================================================
+
+async def main():
+ """Run the MCP server"""
+ logger.info("Web Tools MCP Server starting...")
+ logger.info("Tools: search_web, scrape_website")
+ logger.info("Requirements:")
+ logger.info(" - SearxNG at localhost:8181 (for search_web)")
+ logger.info(" - Selenium Grid at localhost:4444 (for scrape_website)")
+
+ # Run server with stdio transport
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
+ await app.run(
+ read_stream,
+ write_stream,
+ app.create_initialization_options()
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
index c1ef4dfd0f..17b0ad8bc3 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
@@ -4,13 +4,14 @@
ChatMessageActions,
ChatMessageStatistics,
ChatMessageThinkingBlock,
+ ChatMessageToolExecution, // [AI] Tool execution indicator component
CopyToClipboardIcon,
MarkdownContent,
ModelsSelector
} from '$lib/components/app';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
- import { isLoading } from '$lib/stores/chat.svelte';
+ import { isLoading, activeToolExecutions } from '$lib/stores/chat.svelte'; // [AI] Tool execution state
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
import { fade } from 'svelte/transition';
import { Check, X, Wrench } from '@lucide/svelte';
@@ -87,6 +88,10 @@
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
);
const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
+ // [AI] Filter tool executions for this message
+ const messageToolExecutions = $derived(
+ activeToolExecutions().filter((t) => t.messageId === message.id)
+ );
const processingState = useProcessingState();
@@ -332,6 +337,20 @@
{/if}
{/if}
+
+
+ {#if messageToolExecutions.length > 0}
+
+ {#each messageToolExecutions as execution (execution.toolName)}
+
+ {/each}
+
+ {/if}
{#if message.timestamp && !isEditing}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageToolExecution.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageToolExecution.svelte
new file mode 100644
index 0000000000..fa5a176dc5
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageToolExecution.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
+ {toolName}
+ {config.label}
+
+ {#if error}
+ - {error}
+ {/if}
+
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
index 874140feec..5b0562d023 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
@@ -1,16 +1,22 @@
+// [AI] MCP Settings button integration
+
@@ -26,3 +35,4 @@
(settingsOpen = open)} />
+ (mcpSettingsOpen = open)} />
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogMCPSettings.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogMCPSettings.svelte
new file mode 100644
index 0000000000..6f6f50e96b
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/dialogs/DialogMCPSettings.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts
index 8631d4fb3b..9871de6113 100644
--- a/tools/server/webui/src/lib/components/app/index.ts
+++ b/tools/server/webui/src/lib/components/app/index.ts
@@ -21,6 +21,7 @@ export { default as ChatMessageBranchingControls } from './chat/ChatMessages/Cha
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
+export { default as ChatMessageToolExecution } from './chat/ChatMessages/ChatMessageToolExecution.svelte';
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
@@ -48,6 +49,7 @@ export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svel
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
+export { default as DialogMCPSettings } from './dialogs/DialogMCPSettings.svelte';
export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
diff --git a/tools/server/webui/src/lib/components/app/mcp/MCPSettings.svelte b/tools/server/webui/src/lib/components/app/mcp/MCPSettings.svelte
new file mode 100644
index 0000000000..e600dabf48
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/mcp/MCPSettings.svelte
@@ -0,0 +1,251 @@
+
+
+
+
+
+
+
+
+
MCP Servers
+
+
+ Manage Model Context Protocol servers for tool calling
+
+
+
+
+
+
+
+ {#each mcpStore.servers as server (server.id)}
+ {@const StatusIcon = getStatusIcon(server.status)}
+
+
+
+
+
{server.name}
+
+ {server.status}
+
+
+ {server.command}
+ {server.args.join(' ')}
+
+
ID: {server.id}
+
+
+
+ handleToggleServer(server)}
+ />
+
+
+
+
+ {/each}
+
+ {#if mcpStore.servers.length === 0 && !isAdding}
+
+
+
No MCP servers configured
+
+
+ {/if}
+
+
+ {#if isAdding}
+
+
Add New Server
+
+
+
+
+
+
+ {/if}
+
+ {#if mcpStore.servers.length > 0 && !isAdding}
+
+ {/if}
+
+
+ {#if mcpStore.tools.length > 0}
+
+
Available Tools ({mcpStore.tools.length})
+
+ {#each mcpStore.tools as tool}
+
+
{tool.function.name}
+ {#if tool.function.description}
+
+ {tool.function.description}
+
+ {/if}
+
Server: {tool.serverId}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
+
+ {mcpStore.servers.filter((s) => s.enabled).length} enabled •
+ {mcpStore.tools.length} tools available
+
+
+
+
+
diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts
index f9584d01d7..0a8d811897 100644
--- a/tools/server/webui/src/lib/constants/settings-config.ts
+++ b/tools/server/webui/src/lib/constants/settings-config.ts
@@ -40,6 +40,8 @@ export const SETTING_CONFIG_DEFAULT: Record =
dry_penalty_last_n: -1,
max_tokens: -1,
custom: '', // custom json-stringified object
+ // [AI] Tool calling limits
+ maxToolCalls: 10,
// experimental features
pyInterpreterEnabled: false,
enableContinueGeneration: false
@@ -85,6 +87,7 @@ export const SETTING_CONFIG_INFO: Record = {
dry_penalty_last_n:
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
+ maxToolCalls: 'Maximum number of tool calls allowed per message. Prevents infinite tool calling loops. Use -1 for unlimited.',
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
showThoughtInProgress: 'Expand thought process by default when generating messages.',
showToolCalls:
diff --git a/tools/server/webui/src/lib/server/mcp-bridge.ts b/tools/server/webui/src/lib/server/mcp-bridge.ts
new file mode 100644
index 0000000000..a72bd0a56e
--- /dev/null
+++ b/tools/server/webui/src/lib/server/mcp-bridge.ts
@@ -0,0 +1,334 @@
+// [AI] MCP Bridge - Manages MCP server processes via stdio JSON-RPC
+import { spawn, type ChildProcess } from 'child_process';
+import { MCPServerStatus } from '../types';
+import type {
+ MCPServer,
+ MCPTool,
+ MCPToolExecutionRequest,
+ MCPToolExecutionResult
+} from '../types';
+
+/**
+ * MCPBridge - Manages MCP server processes and communication
+ *
+ * This class handles:
+ * - Spawning/stopping MCP server processes
+ * - stdio-based JSON-RPC communication with MCP servers
+ * - Tool schema fetching and caching
+ * - Tool execution routing
+ *
+ * Architecture:
+ * - MCP servers run as child processes
+ * - Communication via stdio using JSON-RPC
+ * - Each server provides tools via list_tools method
+ * - Tool execution routed to appropriate server
+ */
+export class MCPBridge {
+ private processes = new Map();
+ private servers = new Map();
+ private toolCache = new Map();
+ private messageId = 1;
+ private pendingRequests = new Map void;
+ reject: (error: Error) => void;
+ }>();
+
+ constructor() {
+ // Initialize with default servers (can be configured later)
+ this.loadDefaultServers();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Server Management
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private loadDefaultServers(): void {
+ // Load MCP servers from config (for now, empty - will be configured via UI)
+ const defaultServers: MCPServer[] = [];
+
+ for (const server of defaultServers) {
+ this.servers.set(server.id, server);
+ }
+ }
+
+ async listServers(): Promise {
+ return Array.from(this.servers.values());
+ }
+
+ async addServer(server: Omit): Promise {
+ console.log("[MCPBridge] addServer called:", server.id, "enabled:", server.enabled);
+ const newServer: MCPServer = {
+ ...server,
+ status: MCPServerStatus.STOPPED
+ };
+
+ this.servers.set(server.id, newServer);
+
+ if (server.enabled) {
+ console.log("[MCPBridge] Starting server:", server.id);
+ await this.startServer(server.id);
+ }
+
+ return newServer;
+ }
+
+ async updateServer(serverId: string, updates: Partial): Promise {
+ const server = this.servers.get(serverId);
+ if (!server) {
+ throw new Error(`Server ${serverId} not found`);
+ }
+
+ const wasEnabled = server.enabled;
+ const updatedServer = { ...server, ...updates };
+ this.servers.set(serverId, updatedServer);
+
+ // Handle enable/disable state changes
+ if (wasEnabled && !updatedServer.enabled) {
+ await this.stopServer(serverId);
+ } else if (!wasEnabled && updatedServer.enabled) {
+ await this.startServer(serverId);
+ }
+
+ return updatedServer;
+ }
+
+ async removeServer(serverId: string): Promise {
+ await this.stopServer(serverId);
+ this.servers.delete(serverId);
+ this.toolCache.delete(serverId);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Process Lifecycle
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private async startServer(serverId: string): Promise {
+ const server = this.servers.get(serverId);
+ if (!server) {
+ throw new Error(`Server ${serverId} not found`);
+ }
+
+ if (this.processes.has(serverId)) {
+ console.warn(`Server ${serverId} already running`);
+ return;
+ }
+
+ server.status = MCPServerStatus.STARTING;
+
+ try {
+ const childProcess = spawn(server.command, server.args, {
+ env: { ...process.env, ...server.env },
+ stdio: ['pipe', 'pipe', 'pipe']
+ });
+
+ this.processes.set(serverId, childProcess);
+
+ // Handle stdout (JSON-RPC responses)
+ let buffer = '';
+ childProcess.stdout?.on('data', (data) => {
+ buffer += data.toString();
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (line.trim()) {
+ try {
+ const message = JSON.parse(line);
+ this.handleMessage(serverId, message);
+ } catch (error) {
+ console.error(`Failed to parse message from ${serverId}:`, error);
+ }
+ }
+ }
+ });
+
+ // Handle stderr (logging)
+ childProcess.stderr?.on('data', (data) => {
+ console.error(`[${serverId}] ${data.toString()}`);
+ });
+
+ // Handle process exit
+ childProcess.on('exit', (code) => {
+ console.log(`Server ${serverId} exited with code ${code}`);
+ this.processes.delete(serverId);
+ server.status = code === 0 ? MCPServerStatus.STOPPED : MCPServerStatus.ERROR;
+ });
+
+ childProcess.on('error', (error) => {
+ console.error(`Server ${serverId} error:`, error);
+ server.status = MCPServerStatus.ERROR;
+ this.processes.delete(serverId);
+ });
+
+ // Initialize connection and fetch tools
+ await this.initializeServer(serverId);
+
+ server.status = MCPServerStatus.RUNNING;
+ } catch (error) {
+ server.status = MCPServerStatus.ERROR;
+ throw error;
+ }
+ }
+
+ private async stopServer(serverId: string): Promise {
+ const childProcess = this.processes.get(serverId);
+ if (!childProcess) return;
+
+ return new Promise((resolve) => {
+ childProcess.on('exit', () => {
+ this.processes.delete(serverId);
+ const server = this.servers.get(serverId);
+ if (server) {
+ server.status = MCPServerStatus.STOPPED;
+ }
+ resolve();
+ });
+
+ childProcess.kill('SIGTERM');
+
+ // Force kill after 5 seconds
+ setTimeout(() => {
+ if (this.processes.has(serverId)) {
+ childProcess.kill('SIGKILL');
+ }
+ }, 5000);
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // JSON-RPC Communication
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private async sendRequest(serverId: string, method: string, params?: unknown): Promise {
+ const childProcess = this.processes.get(serverId);
+ if (!childProcess) {
+ throw new Error(`Server ${serverId} not running`);
+ }
+
+ const id = this.messageId++;
+ const request = {
+ jsonrpc: '2.0',
+ id,
+ method,
+ ...(params && { params })
+ };
+
+ return new Promise((resolve, reject) => {
+ this.pendingRequests.set(id, { resolve, reject });
+
+ childProcess.stdin?.write(JSON.stringify(request) + '\n');
+
+ // Timeout after 30 seconds
+ setTimeout(() => {
+ if (this.pendingRequests.has(id)) {
+ this.pendingRequests.delete(id);
+ reject(new Error(`Request timeout for ${method}`));
+ }
+ }, 30000);
+ });
+ }
+
+ private handleMessage(serverId: string, message: {
+ jsonrpc: string;
+ id?: number;
+ result?: unknown;
+ error?: { code: number; message: string; data?: unknown };
+ }): void {
+ if (message.id === undefined) return;
+
+ const pending = this.pendingRequests.get(message.id);
+ if (!pending) return;
+
+ this.pendingRequests.delete(message.id);
+
+ if (message.error) {
+ pending.reject(new Error(message.error.message));
+ } else {
+ pending.resolve(message.result);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // MCP Protocol Methods
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private async initializeServer(serverId: string): Promise {
+ // Send initialize request
+ await this.sendRequest(serverId, 'initialize', {
+ protocolVersion: '2024-11-05',
+ capabilities: {
+ tools: {}
+ },
+ clientInfo: {
+ name: 'llama.cpp-webui',
+ version: '1.0.0'
+ }
+ });
+
+ // Fetch available tools
+ await this.fetchTools(serverId);
+ }
+
+ private async fetchTools(serverId: string): Promise {
+ const result = await this.sendRequest(serverId, 'tools/list') as { tools: Array<{
+ name: string;
+ description?: string;
+ inputSchema: Record;
+ }>};
+
+ const tools: MCPTool[] = result.tools.map(tool => ({
+ type: 'function' as const,
+ function: {
+ name: tool.name,
+ description: tool.description,
+ parameters: tool.inputSchema
+ },
+ serverId
+ }));
+
+ this.toolCache.set(serverId, tools);
+ }
+
+ async listTools(): Promise {
+ const allTools: MCPTool[] = [];
+
+ for (const server of this.servers.values()) {
+ if (server.enabled && server.status === MCPServerStatus.RUNNING) {
+ const tools = this.toolCache.get(server.id) || [];
+ allTools.push(...tools);
+ }
+ }
+
+ return allTools;
+ }
+
+ async executeTool(request: MCPToolExecutionRequest): Promise {
+ const { serverId, toolName, arguments: args } = request;
+
+ try {
+ const result = await this.sendRequest(serverId, 'tools/call', {
+ name: toolName,
+ arguments: args
+ }) as { content: Array<{ type: string; text: string }> };
+
+ return {
+ success: true,
+ result: result.content[0]?.text || JSON.stringify(result)
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Cleanup
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ async shutdown(): Promise {
+ const stopPromises = Array.from(this.servers.keys()).map(id => this.stopServer(id));
+ await Promise.all(stopPromises);
+ }
+}
diff --git a/tools/server/webui/src/lib/server/mcp-bridge.ts.bak b/tools/server/webui/src/lib/server/mcp-bridge.ts.bak
new file mode 100644
index 0000000000..3528bf1049
--- /dev/null
+++ b/tools/server/webui/src/lib/server/mcp-bridge.ts.bak
@@ -0,0 +1,332 @@
+// [AI] MCP Bridge - Manages MCP server processes via stdio JSON-RPC
+import { spawn, type ChildProcess } from 'child_process';
+import type {
+ MCPServer,
+ MCPServerStatus,
+ MCPTool,
+ MCPToolExecutionRequest,
+ MCPToolExecutionResult
+} from '$lib/types';
+
+/**
+ * MCPBridge - Manages MCP server processes and communication
+ *
+ * This class handles:
+ * - Spawning/stopping MCP server processes
+ * - stdio-based JSON-RPC communication with MCP servers
+ * - Tool schema fetching and caching
+ * - Tool execution routing
+ *
+ * Architecture:
+ * - MCP servers run as child processes
+ * - Communication via stdio using JSON-RPC
+ * - Each server provides tools via list_tools method
+ * - Tool execution routed to appropriate server
+ */
+export class MCPBridge {
+ private processes = new Map();
+ private servers = new Map();
+ private toolCache = new Map();
+ private messageId = 1;
+ private pendingRequests = new Map void;
+ reject: (error: Error) => void;
+ }>();
+
+ constructor() {
+ // Initialize with default servers (can be configured later)
+ this.loadDefaultServers();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Server Management
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private loadDefaultServers(): void {
+ // Load MCP servers from config (for now, empty - will be configured via UI)
+ const defaultServers: MCPServer[] = [];
+
+ for (const server of defaultServers) {
+ this.servers.set(server.id, server);
+ }
+ }
+
+ async listServers(): Promise {
+ return Array.from(this.servers.values());
+ }
+
+ async addServer(server: Omit): Promise {
+ const newServer: MCPServer = {
+ ...server,
+ status: MCPServerStatus.STOPPED
+ };
+
+ this.servers.set(server.id, newServer);
+
+ if (server.enabled) {
+ await this.startServer(server.id);
+ }
+
+ return newServer;
+ }
+
+ async updateServer(serverId: string, updates: Partial): Promise {
+ const server = this.servers.get(serverId);
+ if (!server) {
+ throw new Error(`Server ${serverId} not found`);
+ }
+
+ const wasEnabled = server.enabled;
+ const updatedServer = { ...server, ...updates };
+ this.servers.set(serverId, updatedServer);
+
+ // Handle enable/disable state changes
+ if (wasEnabled && !updatedServer.enabled) {
+ await this.stopServer(serverId);
+ } else if (!wasEnabled && updatedServer.enabled) {
+ await this.startServer(serverId);
+ }
+
+ return updatedServer;
+ }
+
+ async removeServer(serverId: string): Promise {
+ await this.stopServer(serverId);
+ this.servers.delete(serverId);
+ this.toolCache.delete(serverId);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Process Lifecycle
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private async startServer(serverId: string): Promise {
+ const server = this.servers.get(serverId);
+ if (!server) {
+ throw new Error(`Server ${serverId} not found`);
+ }
+
+ if (this.processes.has(serverId)) {
+ console.warn(`Server ${serverId} already running`);
+ return;
+ }
+
+ server.status = MCPServerStatus.STARTING;
+
+ try {
+ const process = spawn(server.command, server.args, {
+ env: { ...process.env, ...server.env },
+ stdio: ['pipe', 'pipe', 'pipe']
+ });
+
+ this.processes.set(serverId, process);
+
+ // Handle stdout (JSON-RPC responses)
+ let buffer = '';
+ process.stdout?.on('data', (data) => {
+ buffer += data.toString();
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (line.trim()) {
+ try {
+ const message = JSON.parse(line);
+ this.handleMessage(serverId, message);
+ } catch (error) {
+ console.error(`Failed to parse message from ${serverId}:`, error);
+ }
+ }
+ }
+ });
+
+ // Handle stderr (logging)
+ process.stderr?.on('data', (data) => {
+ console.error(`[${serverId}] ${data.toString()}`);
+ });
+
+ // Handle process exit
+ process.on('exit', (code) => {
+ console.log(`Server ${serverId} exited with code ${code}`);
+ this.processes.delete(serverId);
+ server.status = code === 0 ? MCPServerStatus.STOPPED : MCPServerStatus.ERROR;
+ });
+
+ process.on('error', (error) => {
+ console.error(`Server ${serverId} error:`, error);
+ server.status = MCPServerStatus.ERROR;
+ this.processes.delete(serverId);
+ });
+
+ // Initialize connection and fetch tools
+ await this.initializeServer(serverId);
+
+ server.status = MCPServerStatus.RUNNING;
+ } catch (error) {
+ server.status = MCPServerStatus.ERROR;
+ throw error;
+ }
+ }
+
+ private async stopServer(serverId: string): Promise {
+ const process = this.processes.get(serverId);
+ if (!process) return;
+
+ return new Promise((resolve) => {
+ process.on('exit', () => {
+ this.processes.delete(serverId);
+ const server = this.servers.get(serverId);
+ if (server) {
+ server.status = MCPServerStatus.STOPPED;
+ }
+ resolve();
+ });
+
+ process.kill('SIGTERM');
+
+ // Force kill after 5 seconds
+ setTimeout(() => {
+ if (this.processes.has(serverId)) {
+ process.kill('SIGKILL');
+ }
+ }, 5000);
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // JSON-RPC Communication
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private async sendRequest(serverId: string, method: string, params?: unknown): Promise {
+ const process = this.processes.get(serverId);
+ if (!process) {
+ throw new Error(`Server ${serverId} not running`);
+ }
+
+ const id = this.messageId++;
+ const request = {
+ jsonrpc: '2.0',
+ id,
+ method,
+ ...(params && { params })
+ };
+
+ return new Promise((resolve, reject) => {
+ this.pendingRequests.set(id, { resolve, reject });
+
+ process.stdin?.write(JSON.stringify(request) + '\n');
+
+ // Timeout after 30 seconds
+ setTimeout(() => {
+ if (this.pendingRequests.has(id)) {
+ this.pendingRequests.delete(id);
+ reject(new Error(`Request timeout for ${method}`));
+ }
+ }, 30000);
+ });
+ }
+
+ private handleMessage(serverId: string, message: {
+ jsonrpc: string;
+ id?: number;
+ result?: unknown;
+ error?: { code: number; message: string; data?: unknown };
+ }): void {
+ if (message.id === undefined) return;
+
+ const pending = this.pendingRequests.get(message.id);
+ if (!pending) return;
+
+ this.pendingRequests.delete(message.id);
+
+ if (message.error) {
+ pending.reject(new Error(message.error.message));
+ } else {
+ pending.resolve(message.result);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // MCP Protocol Methods
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private async initializeServer(serverId: string): Promise {
+ // Send initialize request
+ await this.sendRequest(serverId, 'initialize', {
+ protocolVersion: '2024-11-05',
+ capabilities: {
+ tools: {}
+ },
+ clientInfo: {
+ name: 'llama.cpp-webui',
+ version: '1.0.0'
+ }
+ });
+
+ // Fetch available tools
+ await this.fetchTools(serverId);
+ }
+
+ private async fetchTools(serverId: string): Promise {
+ const result = await this.sendRequest(serverId, 'tools/list') as { tools: Array<{
+ name: string;
+ description?: string;
+ inputSchema: Record;
+ }>};
+
+ const tools: MCPTool[] = result.tools.map(tool => ({
+ type: 'function' as const,
+ function: {
+ name: tool.name,
+ description: tool.description,
+ parameters: tool.inputSchema
+ },
+ serverId
+ }));
+
+ this.toolCache.set(serverId, tools);
+ }
+
+ async listTools(): Promise {
+ const allTools: MCPTool[] = [];
+
+ for (const server of this.servers.values()) {
+ if (server.enabled && server.status === MCPServerStatus.RUNNING) {
+ const tools = this.toolCache.get(server.id) || [];
+ allTools.push(...tools);
+ }
+ }
+
+ return allTools;
+ }
+
+ async executeTool(request: MCPToolExecutionRequest): Promise {
+ const { serverId, toolName, arguments: args } = request;
+
+ try {
+ const result = await this.sendRequest(serverId, 'tools/call', {
+ name: toolName,
+ arguments: args
+ }) as { content: Array<{ type: string; text: string }> };
+
+ return {
+ success: true,
+ result: result.content[0]?.text || JSON.stringify(result)
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Cleanup
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ async shutdown(): Promise {
+ const stopPromises = Array.from(this.servers.keys()).map(id => this.stopServer(id));
+ await Promise.all(stopPromises);
+ }
+}
diff --git a/tools/server/webui/src/lib/server/vite-plugin-mcp.ts b/tools/server/webui/src/lib/server/vite-plugin-mcp.ts
new file mode 100644
index 0000000000..b33f3e5bb9
--- /dev/null
+++ b/tools/server/webui/src/lib/server/vite-plugin-mcp.ts
@@ -0,0 +1,135 @@
+// [AI] Vite plugin for MCP integration - HTTP API middleware
+import type { Plugin } from 'vite';
+import { MCPBridge } from './mcp-bridge';
+import type {
+ MCPServer,
+ MCPServerListResponse,
+ MCPToolsListResponse,
+ MCPToolExecutionRequest,
+ MCPToolExecutionResult
+} from '../types';
+
+/**
+ * Vite plugin for MCP server management
+ *
+ * Provides HTTP endpoints for MCP server and tool management:
+ * - GET /mcp/servers - List all configured MCP servers
+ * - POST /mcp/servers - Add a new MCP server
+ * - PUT /mcp/servers/:id - Update MCP server configuration
+ * - DELETE /mcp/servers/:id - Remove MCP server
+ * - GET /mcp/tools - List all available tools from enabled servers
+ * - POST /mcp/execute - Execute a tool call
+ */
+export function mcpPlugin(): Plugin {
+ let bridge: MCPBridge | null = null;
+
+ return {
+ name: 'llama.cpp:mcp',
+ apply: 'serve' as const,
+
+ configureServer(server) {
+ bridge = new MCPBridge();
+
+ // Cleanup on server close
+ server.httpServer?.on('close', async () => {
+ await bridge?.shutdown();
+ });
+
+ // Add middleware for MCP endpoints
+ server.middlewares.use(async (req, res, next) => {
+ if (!req.url?.startsWith('/mcp/')) {
+ return next();
+ }
+
+ const url = new URL(req.url, `http://${req.headers.host}`);
+ const path = url.pathname;
+
+ try {
+ // GET /mcp/servers - List servers
+ if (path === '/mcp/servers' && req.method === 'GET') {
+ const servers = await bridge!.listServers();
+ const response: MCPServerListResponse = { servers };
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(response));
+ return;
+ }
+
+ // POST /mcp/servers - Add server
+ if (path === '/mcp/servers' && req.method === 'POST') {
+ const body = await readBody(req);
+ const serverConfig = JSON.parse(body) as Omit;
+ const server = await bridge!.addServer(serverConfig);
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(server));
+ return;
+ }
+
+ // PUT /mcp/servers/:id - Update server
+ const updateMatch = path.match(/^\/mcp\/servers\/([^/]+)$/);
+ if (updateMatch && req.method === 'PUT') {
+ const serverId = decodeURIComponent(updateMatch[1]);
+ const body = await readBody(req);
+ const updates = JSON.parse(body) as Partial;
+ const server = await bridge!.updateServer(serverId, updates);
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(server));
+ return;
+ }
+
+ // DELETE /mcp/servers/:id - Remove server
+ const deleteMatch = path.match(/^\/mcp\/servers\/([^/]+)$/);
+ if (deleteMatch && req.method === 'DELETE') {
+ const serverId = decodeURIComponent(deleteMatch[1]);
+ await bridge!.removeServer(serverId);
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({ success: true }));
+ return;
+ }
+
+ // GET /mcp/tools - List tools
+ if (path === '/mcp/tools' && req.method === 'GET') {
+ const tools = await bridge!.listTools();
+ const response: MCPToolsListResponse = { tools };
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(response));
+ return;
+ }
+
+ // POST /mcp/execute - Execute tool
+ if (path === '/mcp/execute' && req.method === 'POST') {
+ const body = await readBody(req);
+ const request = JSON.parse(body) as MCPToolExecutionRequest;
+ const result = await bridge!.executeTool(request);
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(result));
+ return;
+ }
+
+ // Unknown MCP endpoint
+ res.statusCode = 404;
+ res.end(JSON.stringify({ error: 'Not found' }));
+ } catch (error) {
+ console.error('MCP endpoint error:', error);
+ res.statusCode = 500;
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({
+ error: error instanceof Error ? error.message : 'Internal server error'
+ }));
+ }
+ });
+ }
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Utilities
+// ─────────────────────────────────────────────────────────────────────────────
+
+function readBody(req: NodeJS.ReadableStream): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ req.on('data', (chunk) => chunks.push(chunk));
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
+ req.on('error', reject);
+ });
+}
diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts
index 86648f3cba..fe5ed7fe5a 100644
--- a/tools/server/webui/src/lib/services/chat.ts
+++ b/tools/server/webui/src/lib/services/chat.ts
@@ -66,6 +66,10 @@ export class ChatService {
// Generation parameters
temperature,
max_tokens,
+ // [AI] Tool calling support
+ tools,
+ tool_choice,
+ parallel_tool_calls,
// Sampling parameters
dynatemp_range,
dynatemp_exponent,
@@ -115,7 +119,8 @@ export class ChatService {
const requestBody: ApiChatCompletionRequest = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
role: msg.role,
- content: msg.content
+ content: msg.content,
+ ...(msg.name && { name: msg.name })
})),
stream,
return_progress: stream ? true : undefined
@@ -161,6 +166,13 @@ export class ChatService {
if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
+ // [AI] Include tool calling parameters if tools provided
+ if (tools && tools.length > 0) {
+ requestBody.tools = tools;
+ if (tool_choice !== undefined) requestBody.tool_choice = tool_choice;
+ if (parallel_tool_calls !== undefined) requestBody.parallel_tool_calls = parallel_tool_calls;
+ }
+
if (custom) {
try {
const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
@@ -171,6 +183,13 @@ export class ChatService {
}
try {
+ // [AI] Log request for debugging
+ console.log("[ChatService] Sending request to /v1/chat/completions:", {
+ messages: requestBody.messages.map(m => ({ role: m.role, content: typeof m.content === "string" ? m.content.substring(0, 100) + "..." : "[multipart]" })),
+ tools: requestBody.tools?.length || 0,
+ tool_choice: requestBody.tool_choice,
+ temperature: requestBody.temperature
+ });
const response = await fetch(`./v1/chat/completions`, {
method: 'POST',
headers: getJsonHeaders(),
diff --git a/tools/server/webui/src/lib/services/index.ts b/tools/server/webui/src/lib/services/index.ts
index c36c64a6fa..bc36cccf80 100644
--- a/tools/server/webui/src/lib/services/index.ts
+++ b/tools/server/webui/src/lib/services/index.ts
@@ -1,5 +1,6 @@
export { ChatService } from './chat';
export { DatabaseService } from './database';
+export { MCPService } from './mcp';
export { ModelsService } from './models';
export { PropsService } from './props';
export { ParameterSyncService } from './parameter-sync';
diff --git a/tools/server/webui/src/lib/services/mcp.ts b/tools/server/webui/src/lib/services/mcp.ts
new file mode 100644
index 0000000000..f3d6d946e0
--- /dev/null
+++ b/tools/server/webui/src/lib/services/mcp.ts
@@ -0,0 +1,154 @@
+// [AI] MCP Service - Client-side service layer for MCP server management
+import { base } from '$app/paths';
+import { getJsonHeaders } from '$lib/utils';
+import type {
+ MCPServer,
+ MCPServerListResponse,
+ MCPToolsListResponse,
+ MCPTool,
+ MCPToolExecutionRequest,
+ MCPToolExecutionResult
+} from '$lib/types';
+
+/**
+ * MCPService - Stateless service for MCP server and tool management
+ *
+ * This service handles communication with MCP-related endpoints:
+ * - `/mcp/servers` - MCP server configuration and management
+ * - `/mcp/tools` - Tool listing from enabled servers
+ * - `/mcp/execute` - Tool execution requests
+ *
+ * **Responsibilities:**
+ * - List/add/update/remove MCP servers
+ * - Fetch available tools from running servers
+ * - Execute tool calls and return results
+ *
+ * **Used by:**
+ * - mcpStore: Primary consumer for MCP state management
+ * - chatStore: For tool calling during conversations
+ */
+export class MCPService {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Server Management
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * List all configured MCP servers
+ */
+ static async listServers(): Promise {
+ const response = await fetch(`${base}/mcp/servers`, {
+ headers: getJsonHeaders()
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch MCP servers (status ${response.status})`);
+ }
+
+ const data = (await response.json()) as MCPServerListResponse;
+ return data.servers;
+ }
+
+ /**
+ * Add a new MCP server
+ * @param server - Server configuration (without status field)
+ */
+ static async addServer(server: Omit): Promise {
+ const response = await fetch(`${base}/mcp/servers`, {
+ method: 'POST',
+ headers: getJsonHeaders(),
+ body: JSON.stringify(server)
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || `Failed to add MCP server (status ${response.status})`);
+ }
+
+ return response.json() as Promise;
+ }
+
+ /**
+ * Update an existing MCP server
+ * @param serverId - Server identifier
+ * @param updates - Partial server configuration to update
+ */
+ static async updateServer(
+ serverId: string,
+ updates: Partial
+ ): Promise {
+ const response = await fetch(`${base}/mcp/servers/${encodeURIComponent(serverId)}`, {
+ method: 'PUT',
+ headers: getJsonHeaders(),
+ body: JSON.stringify(updates)
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ errorData.error || `Failed to update MCP server (status ${response.status})`
+ );
+ }
+
+ return response.json() as Promise;
+ }
+
+ /**
+ * Remove an MCP server
+ * @param serverId - Server identifier
+ */
+ static async removeServer(serverId: string): Promise {
+ const response = await fetch(`${base}/mcp/servers/${encodeURIComponent(serverId)}`, {
+ method: 'DELETE',
+ headers: getJsonHeaders()
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ errorData.error || `Failed to remove MCP server (status ${response.status})`
+ );
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Tool Management
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Fetch all available tools from enabled MCP servers
+ * Returns tools in OpenAI-compatible format
+ */
+ static async listTools(): Promise {
+ const response = await fetch(`${base}/mcp/tools`, {
+ headers: getJsonHeaders()
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch MCP tools (status ${response.status})`);
+ }
+
+ const data = (await response.json()) as MCPToolsListResponse;
+ return data.tools;
+ }
+
+ /**
+ * Execute a tool call on an MCP server
+ * @param request - Tool execution request with serverId, toolName, and arguments
+ */
+ static async executeTool(request: MCPToolExecutionRequest): Promise {
+ const response = await fetch(`${base}/mcp/execute`, {
+ method: 'POST',
+ headers: getJsonHeaders(),
+ body: JSON.stringify(request)
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ errorData.error || `Failed to execute tool (status ${response.status})`
+ );
+ }
+
+ return response.json() as Promise;
+ }
+}
diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts
index 67157e36ac..b09013e1f3 100644
--- a/tools/server/webui/src/lib/stores/chat.svelte.ts
+++ b/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -1,4 +1,4 @@
-import { DatabaseService, ChatService } from '$lib/services';
+import { DatabaseService, ChatService, MCPService } from '$lib/services';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
@@ -7,6 +7,7 @@ import {
modelsStore,
selectedModelContextSize
} from '$lib/stores/models.svelte';
+import { mcpStore } from '$lib/stores/mcp.svelte';
import {
normalizeModelName,
filterByLeafNodeId,
@@ -16,6 +17,15 @@ import {
import { SvelteMap } from 'svelte/reactivity';
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
+// [AI] Tool execution state tracking type
+interface ToolExecution {
+ messageId: string;
+ toolName: string;
+ status: 'pending' | 'executing' | 'completed' | 'error';
+ result?: string;
+ error?: string;
+}
+
/**
* chatStore - Active AI interaction and streaming state management
*
@@ -76,7 +86,10 @@ class ChatStore {
private isStreamingActive = $state(false);
private isEditModeActive = $state(false);
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
-
+ // [AI] Tool execution state tracking for real-time UI indicators
+ activeToolExecutions = $state([]);
+ private toolExecutionCleanupTimeouts = new Map();
+
// ─────────────────────────────────────────────────────────────────────────────
// Loading State
// ─────────────────────────────────────────────────────────────────────────────
@@ -253,6 +266,7 @@ class ChatStore {
*/
stopStreaming(): void {
this.isStreamingActive = false;
+ this.clearToolExecutionTimeouts();
}
/**
@@ -262,6 +276,16 @@ class ChatStore {
return this.isStreamingActive;
}
+ /**
+ * [AI] Clear all pending tool execution cleanup timeouts
+ */
+ private clearToolExecutionTimeouts(): void {
+ for (const timeoutId of this.toolExecutionCleanupTimeouts.values()) {
+ clearTimeout(timeoutId);
+ }
+ this.toolExecutionCleanupTimeouts.clear();
+ }
+
private getContextTotal(): number {
const activeState = this.getActiveProcessingState();
@@ -475,6 +499,172 @@ class ChatStore {
);
}
+ /**
+ * [AI] Handle tool execution from assistant tool calls
+ * Executes tools, creates tool result messages, and continues conversation
+ */
+ private async handleToolExecution(
+ toolCallsJson: string,
+ assistantMessage: DatabaseMessage
+ ): Promise {
+ try {
+ // Parse tool calls from JSON
+ const toolCalls = JSON.parse(toolCallsJson) as Array<{
+ id?: string;
+ type: string;
+ function?: {
+ name: string;
+ arguments?: string;
+ };
+ }>;
+
+ if (!toolCalls || toolCalls.length === 0) return;
+
+ // [AI] Cache tools list once to avoid redundant API calls
+ const availableTools = await MCPService.listTools();
+
+ // Execute each tool call
+ for (const toolCall of toolCalls) {
+ if (!toolCall.function?.name) continue;
+
+ const toolName = toolCall.function.name;
+ const argsJson = toolCall.function.arguments || '{}';
+ let args: Record = {};
+
+ try {
+ args = JSON.parse(argsJson);
+ } catch (error) {
+ console.error('Failed to parse tool arguments:', error);
+ args = {};
+ }
+
+ // Add to active tool executions
+ this.activeToolExecutions = [
+ ...this.activeToolExecutions,
+ { messageId: assistantMessage.id, toolName, status: 'pending' }
+ ];
+
+ try {
+ // Determine server ID from tool name using cached list
+ const matchingTool = availableTools.find((t) => t.function.name === toolName);
+
+ if (!matchingTool) {
+ console.error(`Tool ${toolName} not found in available MCP tools`);
+ // Update status to error
+ this.activeToolExecutions = this.activeToolExecutions.map((t) =>
+ t.messageId === assistantMessage.id && t.toolName === toolName
+ ? { ...t, status: 'error' as const, error: 'Tool not found' }
+ : t
+ );
+ continue;
+ }
+
+ // Update status to executing
+ this.activeToolExecutions = this.activeToolExecutions.map((t) =>
+ t.messageId === assistantMessage.id && t.toolName === toolName
+ ? { ...t, status: 'executing' as const }
+ : t
+ );
+
+ // Execute the tool
+ const result = await MCPService.executeTool({
+ serverId: matchingTool.serverId,
+ toolName,
+ arguments: args
+ });
+
+ // Create tool result message
+ const toolResultContent = result.success
+ ? typeof result.result === 'string'
+ ? result.result
+ : JSON.stringify(result.result)
+ : `Error: ${result.error}`;
+
+ // Update status to completed or error
+ this.activeToolExecutions = this.activeToolExecutions.map((t) =>
+ t.messageId === assistantMessage.id && t.toolName === toolName
+ ? {
+ ...t,
+ status: (result.success ? 'completed' : 'error') as const,
+ result: result.success ? toolResultContent : undefined,
+ error: result.success ? undefined : result.error
+ }
+ : t
+ );
+
+ // Add tool result as a new message
+ const toolMessage = await DatabaseService.createMessageBranch(
+ {
+ convId: assistantMessage.convId,
+ type: 'text',
+ role: 'tool',
+ name: toolName,
+ content: toolResultContent,
+ timestamp: Date.now(),
+ thinking: '',
+ toolCalls: '',
+ children: [],
+ model: null
+ },
+ assistantMessage.id
+ );
+
+ if (toolMessage) {
+ conversationsStore.addMessageToActive(toolMessage);
+ }
+ } catch (error) {
+ // Update status to error
+ this.activeToolExecutions = this.activeToolExecutions.map((t) =>
+ t.messageId === assistantMessage.id && t.toolName === toolName
+ ? {
+ ...t,
+ status: 'error' as const,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ }
+ : t
+ );
+ }
+ }
+
+ // [AI] Clear completed tool executions after delay (keep errors visible)
+ // Cancel any existing cleanup timeout for this message
+ const existingTimeout = this.toolExecutionCleanupTimeouts.get(assistantMessage.id);
+ if (existingTimeout) {
+ clearTimeout(existingTimeout);
+ }
+
+ // Schedule cleanup of completed executions only
+ const timeoutId = setTimeout(() => {
+ this.activeToolExecutions = this.activeToolExecutions.filter(
+ (t) => !(t.messageId === assistantMessage.id && t.status === 'completed')
+ );
+ this.toolExecutionCleanupTimeouts.delete(assistantMessage.id);
+ }, 3000);
+
+ this.toolExecutionCleanupTimeouts.set(assistantMessage.id, timeoutId as unknown as number);
+
+ // After all tools executed, create new assistant message to generate final response
+ const continuationMessage = await this.createAssistantMessage(
+ conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]?.id
+ );
+
+ if (!continuationMessage) {
+ throw new Error('Failed to create continuation message');
+ }
+
+ conversationsStore.addMessageToActive(continuationMessage);
+
+ // Continue conversation with tool results included
+ await this.streamChatCompletion(
+ conversationsStore.activeMessages.slice(0, -1),
+ continuationMessage
+ );
+ } catch (error) {
+ console.error('Tool execution error:', error);
+ // Don't throw - conversation can continue even if tools fail
+ }
+ }
+
private async streamChatCompletion(
allMessages: DatabaseMessage[],
assistantMessage: DatabaseMessage,
@@ -588,6 +778,15 @@ class ChatStore {
conversationsStore.updateMessageAtIndex(idx, uiUpdate);
await conversationsStore.updateCurrentNode(assistantMessage.id);
+ // Handle tool calls if present
+ const finalToolCalls = toolCallContent || streamedToolCallContent;
+ if (finalToolCalls && finalToolCalls.trim()) {
+ // Execute tools and continue conversation
+ await this.handleToolExecution(finalToolCalls, assistantMessage);
+ // Don't clear loading state yet - handleToolExecution will trigger another completion
+ return;
+ }
+
if (onComplete) await onComplete(streamedContent);
this.setChatLoading(assistantMessage.convId, false);
this.clearChatStreaming(assistantMessage.convId);
@@ -1463,6 +1662,14 @@ class ChatStore {
if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;
if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
+ // Include MCP tools if available
+ console.log("MCP tools check:", { hasTools: mcpStore.hasTools(), toolCount: mcpStore.tools.length, tools: mcpStore.getToolsForAPI() });
+ if (mcpStore.hasTools()) {
+ apiOptions.tools = mcpStore.getToolsForAPI();
+ apiOptions.tool_choice = 'auto';
+ apiOptions.parallel_tool_calls = true;
+ }
+
return apiOptions;
}
}
@@ -1470,6 +1677,8 @@ class ChatStore {
export const chatStore = new ChatStore();
export const activeProcessingState = () => chatStore.activeProcessingState;
+// [AI] Export tool execution state accessor
+export const activeToolExecutions = () => chatStore.activeToolExecutions;
export const clearEditMode = () => chatStore.clearEditMode();
export const currentResponse = () => chatStore.currentResponse;
export const errorDialog = () => chatStore.errorDialogState;
diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts
new file mode 100644
index 0000000000..88495d85d3
--- /dev/null
+++ b/tools/server/webui/src/lib/stores/mcp.svelte.ts
@@ -0,0 +1,232 @@
+// [AI] MCP Store - Reactive Svelte 5 store for MCP servers and tools
+import { MCPService } from '$lib/services';
+import type { MCPServer, MCPTool } from '$lib/types';
+import { MCPServerStatus } from '$lib/types';
+
+const MCP_SERVERS_STORAGE_KEY = 'mcp_servers';
+
+/**
+ * mcpStore - MCP server and tool state management
+ *
+ * Manages:
+ * - List of configured MCP servers
+ * - Available tools from enabled servers
+ * - Server enable/disable state
+ * - Persistence to localStorage
+ */
+class MCPStore {
+ servers = $state([]);
+ tools = $state([]);
+ isLoading = $state(false);
+ error = $state(null);
+
+ constructor() {
+ // Load servers from localStorage on construction
+ this.loadFromLocalStorage();
+ }
+
+ /**
+ * Load servers from localStorage
+ */
+ private loadFromLocalStorage(): void {
+ if (typeof window === 'undefined') return;
+
+ try {
+ const stored = localStorage.getItem(MCP_SERVERS_STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored) as Array>;
+ // Don't set servers directly - they'll be synced with backend during initialize
+ // Just store them for use during initialization
+ this.pendingServers = parsed;
+ }
+ } catch (error) {
+ console.error('Failed to load MCP servers from localStorage:', error);
+ }
+ }
+
+ /**
+ * Save servers to localStorage
+ */
+ private saveToLocalStorage(): void {
+ if (typeof window === 'undefined') return;
+
+ try {
+ // Store servers without status (status is runtime only)
+ const toStore = this.servers.map(({ status, ...rest }) => rest);
+ localStorage.setItem(MCP_SERVERS_STORAGE_KEY, JSON.stringify(toStore));
+ } catch (error) {
+ console.error('Failed to save MCP servers to localStorage:', error);
+ }
+ }
+
+ private pendingServers: Array> = [];
+
+ /**
+ * Initialize store by syncing with backend and loading persisted servers
+ */
+ async initialize(): Promise {
+ console.log("[MCPStore] Initializing, pending servers:", this.pendingServers.length);
+ this.isLoading = true;
+ this.error = null;
+
+ try {
+ // First, restore any persisted servers to the backend
+ if (this.pendingServers.length > 0) {
+ // Fetch existing servers from backend to avoid duplicates
+ const existingServers = await MCPService.listServers();
+ const existingIds = new Set(existingServers.map(s => s.id));
+
+ for (const server of this.pendingServers) {
+ try {
+ // Only add if server doesn't already exist in backend
+ if (!existingIds.has(server.id)) {
+ await MCPService.addServer(server);
+ } else {
+ console.log('[MCPStore] Server already exists in backend:', server.id);
+ }
+ } catch (error) {
+ // Server might already exist, that's okay
+ console.warn('Failed to restore server:', server.id, error);
+ }
+ }
+ console.log("[MCPStore] Restored servers from localStorage");
+ this.pendingServers = [];
+ }
+
+ // Then fetch current state from backend
+ await this.fetchServers();
+ console.log("[MCPStore] Fetched servers from backend:", this.servers.length, "servers");
+
+ // [AI] Ensure all enabled servers are running (handles page reload case)
+ for (const server of this.servers) {
+ if (server.enabled && server.status !== 'running' && server.status !== 'starting') {
+ console.log('[MCPStore] Starting enabled server after reload:', server.id);
+ try {
+ await MCPService.updateServer(server.id, { enabled: true });
+ } catch (error) {
+ console.error('[MCPStore] Failed to start server:', server.id, error);
+ }
+ }
+ }
+
+ await this.fetchTools();
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : 'Failed to initialize MCP';
+ console.error('MCP initialization error:', error);
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+ /**
+ * Fetch list of configured MCP servers
+ */
+ async fetchServers(): Promise {
+ try {
+ this.servers = await MCPService.listServers();
+ } catch (error) {
+ console.error('Failed to fetch MCP servers:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Fetch available tools from enabled servers
+ */
+ async fetchTools(): Promise {
+ try {
+ console.log("Fetching MCP tools...");
+ this.tools = await MCPService.listTools();
+ console.log("Fetched MCP tools:", this.tools);
+ } catch (error) {
+ console.error('Failed to fetch MCP tools:', error);
+ // Don't throw - tools list can be empty
+ this.tools = [];
+ }
+ }
+
+ /**
+ * Add a new MCP server
+ */
+ async addServer(server: Omit): Promise {
+ try {
+ const newServer = await MCPService.addServer(server);
+ this.servers = [...this.servers, newServer];
+ this.saveToLocalStorage();
+
+ if (newServer.enabled) {
+ await this.fetchTools();
+ }
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : 'Failed to add server';
+ console.error('Failed to add MCP server:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Update an existing MCP server
+ */
+ async updateServer(serverId: string, updates: Partial): Promise {
+ try {
+ const updatedServer = await MCPService.updateServer(serverId, updates);
+ this.servers = this.servers.map((s) => (s.id === serverId ? updatedServer : s));
+ this.saveToLocalStorage();
+
+ // Refetch tools if enabled state changed
+ if ('enabled' in updates) {
+ await this.fetchTools();
+ }
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : 'Failed to update server';
+ console.error('Failed to update MCP server:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Remove an MCP server
+ */
+ async removeServer(serverId: string): Promise {
+ try {
+ await MCPService.removeServer(serverId);
+ this.servers = this.servers.filter((s) => s.id !== serverId);
+ this.saveToLocalStorage();
+ await this.fetchTools();
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : 'Failed to remove server';
+ console.error('Failed to remove MCP server:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get tools in OpenAI-compatible format for chat completion requests
+ */
+ getToolsForAPI(): Array<{ type: 'function'; function: { name: string; description?: string; parameters?: Record } }> {
+ return this.tools.map((tool) => ({
+ type: 'function' as const,
+ function: {
+ name: tool.function.name,
+ description: tool.function.description,
+ parameters: tool.function.parameters
+ }
+ }));
+ }
+
+ /**
+ * Check if any servers are enabled
+ */
+ hasEnabledServers(): boolean {
+ return this.servers.some((s) => s.enabled);
+ }
+
+ /**
+ * Check if tools are available
+ */
+ hasTools(): boolean {
+ return this.tools.length > 0;
+ }
+}
+
+export const mcpStore = new MCPStore();
diff --git a/tools/server/webui/src/lib/types/api.d.ts b/tools/server/webui/src/lib/types/api.d.ts
index c2ecc02820..486bee66d8 100644
--- a/tools/server/webui/src/lib/types/api.d.ts
+++ b/tools/server/webui/src/lib/types/api.d.ts
@@ -35,6 +35,8 @@ export interface ApiChatMessageData {
role: ChatRole;
content: string | ApiChatMessageContentPart[];
timestamp?: number;
+ // [AI] Tool message name field
+ name?: string;
}
/**
@@ -215,6 +217,20 @@ export interface ApiChatCompletionRequest {
// Custom parameters (JSON string)
custom?: Record;
timings_per_token?: boolean;
+ // [AI] Tool calling support
+ tools?: ApiChatCompletionTool[];
+ tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
+ parallel_tool_calls?: boolean;
+}
+
+// [AI] OpenAI-compatible tool definition
+export interface ApiChatCompletionTool {
+ type: 'function';
+ function: {
+ name: string;
+ description?: string;
+ parameters?: Record;
+ };
}
export interface ApiChatCompletionToolCallFunctionDelta {
diff --git a/tools/server/webui/src/lib/types/chat.d.ts b/tools/server/webui/src/lib/types/chat.d.ts
index 0e706b72b6..8edb079286 100644
--- a/tools/server/webui/src/lib/types/chat.d.ts
+++ b/tools/server/webui/src/lib/types/chat.d.ts
@@ -1,5 +1,5 @@
export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
-export type ChatRole = 'user' | 'assistant' | 'system';
+export type ChatRole = 'user' | 'assistant' | 'system' | 'tool';
export interface ChatUploadedFile {
id: string;
diff --git a/tools/server/webui/src/lib/types/index.ts b/tools/server/webui/src/lib/types/index.ts
index 2a21c6dcfa..b8e519fdfb 100644
--- a/tools/server/webui/src/lib/types/index.ts
+++ b/tools/server/webui/src/lib/types/index.ts
@@ -15,6 +15,7 @@ export type {
ApiModelListResponse,
ApiLlamaCppServerProps,
ApiChatCompletionRequest,
+ ApiChatCompletionTool,
ApiChatCompletionToolCallFunctionDelta,
ApiChatCompletionToolCallDelta,
ApiChatCompletionToolCall,
@@ -68,3 +69,15 @@ export type {
SettingsChatServiceOptions,
SettingsConfigType
} from './settings';
+
+// [AI] MCP types
+export type {
+ MCPServer,
+ MCPServerStatus,
+ MCPTool,
+ MCPToolExecutionRequest,
+ MCPToolExecutionResult,
+ MCPServerListResponse,
+ MCPToolsListResponse
+} from './mcp';
+export { MCPServerStatus } from './mcp';
diff --git a/tools/server/webui/src/lib/types/mcp.ts b/tools/server/webui/src/lib/types/mcp.ts
new file mode 100644
index 0000000000..7990eb506e
--- /dev/null
+++ b/tools/server/webui/src/lib/types/mcp.ts
@@ -0,0 +1,69 @@
+// [AI] MCP type definitions for Model Context Protocol integration
+import type { ApiChatCompletionTool } from './api';
+
+/**
+ * MCP Server configuration
+ */
+export interface MCPServer {
+ /** Unique identifier for the server */
+ id: string;
+ /** Display name */
+ name: string;
+ /** Command to execute (e.g., "python", "node") */
+ command: string;
+ /** Arguments to pass (e.g., ["path/to/server.py"]) */
+ args: string[];
+ /** Environment variables */
+ env?: Record;
+ /** Whether the server is currently enabled */
+ enabled: boolean;
+ /** Current connection status */
+ status: MCPServerStatus;
+}
+
+export enum MCPServerStatus {
+ STOPPED = 'stopped',
+ STARTING = 'starting',
+ RUNNING = 'running',
+ ERROR = 'error'
+}
+
+/**
+ * MCP Tool (converted from MCP tool schema to OpenAI format)
+ */
+export interface MCPTool extends ApiChatCompletionTool {
+ /** Server ID that provides this tool */
+ serverId: string;
+}
+
+/**
+ * MCP Tool execution request
+ */
+export interface MCPToolExecutionRequest {
+ serverId: string;
+ toolName: string;
+ arguments: Record;
+}
+
+/**
+ * MCP Tool execution result
+ */
+export interface MCPToolExecutionResult {
+ success: boolean;
+ result?: unknown;
+ error?: string;
+}
+
+/**
+ * MCP Server list response from backend
+ */
+export interface MCPServerListResponse {
+ servers: MCPServer[];
+}
+
+/**
+ * MCP Tools list response from backend
+ */
+export interface MCPToolsListResponse {
+ tools: MCPTool[];
+}
diff --git a/tools/server/webui/src/lib/types/settings.d.ts b/tools/server/webui/src/lib/types/settings.d.ts
index e09f0f332c..b8d49de440 100644
--- a/tools/server/webui/src/lib/types/settings.d.ts
+++ b/tools/server/webui/src/lib/types/settings.d.ts
@@ -1,5 +1,6 @@
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
-import type { ChatMessageTimings } from './chat';
+import type { ChatMessageTimings, ChatMessagePromptProgress } from './chat';
+import type { ApiChatCompletionTool } from './api';
export type SettingsConfigValue = string | number | boolean;
@@ -46,6 +47,10 @@ export interface SettingsChatServiceOptions {
// Custom parameters
custom?: string;
timings_per_token?: boolean;
+ // [AI] Tool calling
+ tools?: ApiChatCompletionTool[];
+ tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
+ parallel_tool_calls?: boolean;
// Callbacks
onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void;
diff --git a/tools/server/webui/src/routes/+layout.svelte b/tools/server/webui/src/routes/+layout.svelte
index 095827b9ca..208d48284e 100644
--- a/tools/server/webui/src/routes/+layout.svelte
+++ b/tools/server/webui/src/routes/+layout.svelte
@@ -14,6 +14,7 @@
import { Toaster } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { modelsStore } from '$lib/stores/models.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
@@ -141,6 +142,20 @@
}
});
+ // [AI] Initialize MCP store on app load (run once)
+ let mcpInitialized = false;
+
+ $effect(() => {
+ if (!mcpInitialized) {
+ mcpInitialized = true;
+ untrack(() => {
+ mcpStore.initialize().catch((error) => {
+ console.error('MCP initialization failed:', error);
+ });
+ });
+ }
+ });
+
// Monitor API key changes and redirect to error page if removed or changed when required
$effect(() => {
const apiKey = config().apiKey;
diff --git a/tools/server/webui/vite.config.ts b/tools/server/webui/vite.config.ts
index 5183c09fca..27d065f5a0 100644
--- a/tools/server/webui/vite.config.ts
+++ b/tools/server/webui/vite.config.ts
@@ -6,6 +6,8 @@ import { resolve } from 'path';
import { defineConfig } from 'vite';
import devtoolsJson from 'vite-plugin-devtools-json';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
+// [AI] MCP integration plugin
+import { mcpPlugin } from './src/lib/server/vite-plugin-mcp';
const GUIDE_FOR_FRONTEND = `