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 = `