From 1532413ea72316b276cace8322fce18e6767fa23 Mon Sep 17 00:00:00 2001 From: brucepro Date: Sat, 27 Dec 2025 21:21:51 -0800 Subject: [PATCH] add in mcp server support to frontend webui needed to test my memory mcp, sso had claude add in mcp support to the webui. All code should be tagged as needed with the AI tag. tested a bit, seems to work. Need to add in permission system with toast or another dialog system. --- .../dockers/searxng/docker-compose.yml | 23 ++ .../dockers/selenium_grid/docker-compose.yml | 41 +++ .../webui/docs/example_mcp/web_tools_mcp.py | 344 ++++++++++++++++++ .../ChatMessages/ChatMessageAssistant.svelte | 21 +- .../ChatMessageToolExecution.svelte | 60 +++ .../chat/ChatScreen/ChatScreenHeader.svelte | 14 +- .../app/dialogs/DialogMCPSettings.svelte | 30 ++ .../webui/src/lib/components/app/index.ts | 2 + .../lib/components/app/mcp/MCPSettings.svelte | 251 +++++++++++++ .../src/lib/constants/settings-config.ts | 3 + .../server/webui/src/lib/server/mcp-bridge.ts | 334 +++++++++++++++++ .../webui/src/lib/server/mcp-bridge.ts.bak | 332 +++++++++++++++++ .../webui/src/lib/server/vite-plugin-mcp.ts | 135 +++++++ tools/server/webui/src/lib/services/chat.ts | 21 +- tools/server/webui/src/lib/services/index.ts | 1 + tools/server/webui/src/lib/services/mcp.ts | 154 ++++++++ .../webui/src/lib/stores/chat.svelte.ts | 213 ++++++++++- .../server/webui/src/lib/stores/mcp.svelte.ts | 232 ++++++++++++ tools/server/webui/src/lib/types/api.d.ts | 16 + tools/server/webui/src/lib/types/chat.d.ts | 2 +- tools/server/webui/src/lib/types/index.ts | 13 + tools/server/webui/src/lib/types/mcp.ts | 69 ++++ .../server/webui/src/lib/types/settings.d.ts | 7 +- tools/server/webui/src/routes/+layout.svelte | 15 + tools/server/webui/vite.config.ts | 4 +- 25 files changed, 2328 insertions(+), 9 deletions(-) create mode 100644 tools/server/webui/docs/example_mcp/dockers/searxng/docker-compose.yml create mode 100644 tools/server/webui/docs/example_mcp/dockers/selenium_grid/docker-compose.yml create mode 100644 tools/server/webui/docs/example_mcp/web_tools_mcp.py create mode 100644 tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageToolExecution.svelte create mode 100644 tools/server/webui/src/lib/components/app/dialogs/DialogMCPSettings.svelte create mode 100644 tools/server/webui/src/lib/components/app/mcp/MCPSettings.svelte create mode 100644 tools/server/webui/src/lib/server/mcp-bridge.ts create mode 100644 tools/server/webui/src/lib/server/mcp-bridge.ts.bak create mode 100644 tools/server/webui/src/lib/server/vite-plugin-mcp.ts create mode 100644 tools/server/webui/src/lib/services/mcp.ts create mode 100644 tools/server/webui/src/lib/stores/mcp.svelte.ts create mode 100644 tools/server/webui/src/lib/types/mcp.ts 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 8997963f16..262409c914 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(); let currentConfig = $derived(config()); @@ -308,6 +313,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 c03b764419..f819c21ac3 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 }; @@ -160,6 +165,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; @@ -170,6 +182,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 0108894524..ad737852d2 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(); @@ -468,6 +492,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, @@ -581,6 +771,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); @@ -1456,6 +1655,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; } } @@ -1463,6 +1670,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 e5fde24c75..f91f0874d6 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; } /** @@ -214,6 +216,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 40de98b708..0e135b0bef 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 = `