This commit is contained in:
brucepro 2026-01-03 07:00:30 +09:00 committed by GitHub
commit 54b4e5518f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2328 additions and 9 deletions

View File

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

View File

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

View File

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

View File

@ -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 @@
</span>
{/if}
{/if}
<!-- [AI] Tool execution indicators -->
{#if messageToolExecutions.length > 0}
<div class="mt-2 flex flex-wrap gap-2">
{#each messageToolExecutions as execution (execution.toolName)}
<ChatMessageToolExecution
toolName={execution.toolName}
status={execution.status}
result={execution.result}
error={execution.error}
/>
{/each}
</div>
{/if}
</div>
{#if message.timestamp && !isEditing}

View File

@ -0,0 +1,60 @@
<!-- [AI] Tool execution status indicator component -->
<script lang="ts">
import { Loader2, CheckCircle2, XCircle, Wrench } from '@lucide/svelte';
import { fade } from 'svelte/transition';
interface Props {
toolName: string;
status: 'pending' | 'executing' | 'completed' | 'error';
result?: string;
error?: string;
}
let { toolName, status, result, error }: Props = $props();
const statusConfig = {
pending: {
icon: Wrench,
color: 'text-muted-foreground',
bgColor: 'bg-muted-foreground/10',
label: 'Pending'
},
executing: {
icon: Loader2,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
label: 'Executing'
},
completed: {
icon: CheckCircle2,
color: 'text-green-500',
bgColor: 'bg-green-500/10',
label: 'Completed'
},
error: {
icon: XCircle,
color: 'text-red-500',
bgColor: 'bg-red-500/10',
label: 'Error'
}
};
const config = $derived(statusConfig[status]);
const Icon = $derived(config.icon);
</script>
<div
class="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm {config.bgColor}"
transition:fade
>
<svelte:component
this={Icon}
class="h-4 w-4 {config.color} {status === 'executing' ? 'animate-spin' : ''}"
/>
<span class="font-mono">{toolName}</span>
<span class="text-xs {config.color}">{config.label}</span>
{#if error}
<span class="text-xs text-red-500">- {error}</span>
{/if}
</div>

View File

@ -1,16 +1,22 @@
// [AI] MCP Settings button integration
<script lang="ts">
import { Settings } from '@lucide/svelte';
import { DialogChatSettings } from '$lib/components/app';
import { Settings, Puzzle } from '@lucide/svelte';
import { DialogChatSettings, DialogMCPSettings } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { useSidebar } from '$lib/components/ui/sidebar';
const sidebar = useSidebar();
let settingsOpen = $state(false);
let mcpSettingsOpen = $state(false);
function toggleSettings() {
settingsOpen = true;
}
function toggleMCPSettings() {
mcpSettingsOpen = true;
}
</script>
<header
@ -19,6 +25,9 @@
: ''}"
>
<div class="pointer-events-auto flex items-center space-x-2">
<Button variant="ghost" size="sm" onclick={toggleMCPSettings}>
<Puzzle class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onclick={toggleSettings}>
<Settings class="h-4 w-4" />
</Button>
@ -26,3 +35,4 @@
</header>
<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
<DialogMCPSettings open={mcpSettingsOpen} onOpenChange={(open) => (mcpSettingsOpen = open)} />

View File

@ -0,0 +1,30 @@
<!-- [AI] MCP Settings dialog wrapper -->
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import MCPSettings from '../mcp/MCPSettings.svelte';
interface Props {
onOpenChange?: (open: boolean) => void;
open?: boolean;
}
let { onOpenChange, open = false }: Props = $props();
function handleClose() {
onOpenChange?.(false);
}
function handleSave() {
onOpenChange?.(false);
}
</script>
<Dialog.Root {open} onOpenChange={handleClose}>
<Dialog.Content
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
md:h-[70vh] md:max-h-[70vh] md:min-h-0 md:rounded-lg"
style="max-width: 48rem;"
>
<MCPSettings onSave={handleSave} />
</Dialog.Content>
</Dialog.Root>

View File

@ -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';

View File

@ -0,0 +1,251 @@
<!-- [AI] MCP Settings UI component -->
<script lang="ts">
import { Server, Plus, Trash2, Power, PowerOff } from '@lucide/svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { MCPServerStatus, type MCPServer } from '$lib/types';
interface Props {
onSave?: () => void;
}
let { onSave }: Props = $props();
let newServer = $state({
id: '',
name: '',
command: '',
args: '',
enabled: true
});
let isAdding = $state(false);
async function handleAddServer() {
console.log('handleAddServer called', { id: newServer.id, name: newServer.name, command: newServer.command, args: newServer.args });
if (!newServer.id || !newServer.name || !newServer.command) {
return;
}
try {
await mcpStore.addServer({
id: newServer.id,
name: newServer.name,
command: newServer.command,
args: newServer.args
.split(" ")
.map(a => a.trim().replace(/^["']|["']$/g, ""))
.filter((a) => a),
enabled: newServer.enabled
});
// Reset form
newServer = {
id: '',
name: '',
command: '',
args: '',
enabled: true
};
isAdding = false;
} catch (error) {
console.error('Failed to add server:', error);
}
}
async function handleToggleServer(server: MCPServer) {
try {
await mcpStore.updateServer(server.id, { enabled: !server.enabled });
} catch (error) {
console.error('Failed to toggle server:', error);
}
}
async function handleRemoveServer(serverId: string) {
if (!confirm('Are you sure you want to remove this server?')) {
return;
}
try {
await mcpStore.removeServer(serverId);
} catch (error) {
console.error('Failed to remove server:', error);
}
}
function getStatusColor(status: MCPServerStatus): string {
switch (status) {
case MCPServerStatus.RUNNING:
return 'text-green-500';
case MCPServerStatus.STARTING:
return 'text-yellow-500';
case MCPServerStatus.ERROR:
return 'text-red-500';
default:
return 'text-gray-500';
}
}
function getStatusIcon(status: MCPServerStatus) {
return status === MCPServerStatus.RUNNING ? Power : PowerOff;
}
</script>
<div class="flex h-full flex-col">
<!-- Header -->
<div class="border-b p-6">
<div class="flex items-center gap-3">
<Server class="h-6 w-6" />
<h2 class="text-2xl font-semibold">MCP Servers</h2>
</div>
<p class="mt-2 text-sm text-muted-foreground">
Manage Model Context Protocol servers for tool calling
</p>
</div>
<!-- Content -->
<ScrollArea class="flex-1 p-6">
<div class="space-y-4">
<!-- Server List -->
{#each mcpStore.servers as server (server.id)}
{@const StatusIcon = getStatusIcon(server.status)}
<div class="rounded-lg border p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-medium">{server.name}</h3>
<StatusIcon class="h-4 w-4 {getStatusColor(server.status)}" />
<span class="text-xs text-muted-foreground">{server.status}</span>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{server.command}
{server.args.join(' ')}
</p>
<p class="mt-1 text-xs text-muted-foreground">ID: {server.id}</p>
</div>
<div class="flex items-center gap-2">
<Switch
checked={server.enabled}
onCheckedChange={() => handleToggleServer(server)}
/>
<Button
variant="ghost"
size="icon"
onclick={() => handleRemoveServer(server.id)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/each}
{#if mcpStore.servers.length === 0 && !isAdding}
<div class="rounded-lg border border-dashed p-8 text-center">
<Server class="mx-auto h-12 w-12 text-muted-foreground" />
<p class="mt-4 text-sm text-muted-foreground">No MCP servers configured</p>
<Button class="mt-4" onclick={() => (isAdding = true)}>
<Plus class="mr-2 h-4 w-4" />
Add Server
</Button>
</div>
{/if}
<!-- Add Server Form -->
{#if isAdding}
<div class="rounded-lg border bg-muted/50 p-4">
<h3 class="mb-4 font-medium">Add New Server</h3>
<div class="space-y-3">
<div>
<Label for="server-id">Server ID</Label>
<Input
id="server-id"
bind:value={newServer.id}
placeholder="web-tools"
class="mt-1"
/>
</div>
<div>
<Label for="server-name">Name</Label>
<Input
id="server-name"
bind:value={newServer.name}
placeholder="Web Tools"
class="mt-1"
/>
</div>
<div>
<Label for="server-command">Command</Label>
<Input
id="server-command"
bind:value={newServer.command}
placeholder="python"
class="mt-1"
/>
</div>
<div>
<Label for="server-args">Arguments (space-separated)</Label>
<Input
id="server-args"
bind:value={newServer.args}
placeholder="path/to/server.py"
class="mt-1"
/>
</div>
<div class="flex items-center gap-2">
<Switch bind:checked={newServer.enabled} id="server-enabled" />
<Label for="server-enabled">Enable immediately</Label>
</div>
</div>
<div class="mt-4 flex gap-2">
<Button onclick={handleAddServer}>Add Server</Button>
<Button variant="outline" onclick={() => (isAdding = false)}>Cancel</Button>
</div>
</div>
{/if}
{#if mcpStore.servers.length > 0 && !isAdding}
<Button variant="outline" class="w-full" onclick={() => (isAdding = true)}>
<Plus class="mr-2 h-4 w-4" />
Add Another Server
</Button>
{/if}
<!-- Tool List -->
{#if mcpStore.tools.length > 0}
<div class="mt-6">
<h3 class="mb-3 font-medium">Available Tools ({mcpStore.tools.length})</h3>
<div class="space-y-2">
{#each mcpStore.tools as tool}
<div class="rounded-md border p-3">
<div class="font-mono text-sm">{tool.function.name}</div>
{#if tool.function.description}
<p class="mt-1 text-xs text-muted-foreground">
{tool.function.description}
</p>
{/if}
<p class="mt-1 text-xs text-muted-foreground">Server: {tool.serverId}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</ScrollArea>
<!-- Footer -->
<div class="border-t p-4">
<div class="flex justify-between">
<div class="text-sm text-muted-foreground">
{mcpStore.servers.filter((s) => s.enabled).length} enabled •
{mcpStore.tools.length} tools available
</div>
<Button onclick={onSave}>Done</Button>
</div>
</div>
</div>

View File

@ -40,6 +40,8 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
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<string, string> = {
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:

View File

@ -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<string, ChildProcess>();
private servers = new Map<string, MCPServer>();
private toolCache = new Map<string, MCPTool[]>();
private messageId = 1;
private pendingRequests = new Map<number, {
resolve: (value: unknown) => 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<MCPServer[]> {
return Array.from(this.servers.values());
}
async addServer(server: Omit<MCPServer, 'status'>): Promise<MCPServer> {
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<MCPServer>): Promise<MCPServer> {
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<void> {
await this.stopServer(serverId);
this.servers.delete(serverId);
this.toolCache.delete(serverId);
}
// ─────────────────────────────────────────────────────────────────────────────
// Process Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
private async startServer(serverId: string): Promise<void> {
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<void> {
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<unknown> {
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<void> {
// 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<void> {
const result = await this.sendRequest(serverId, 'tools/list') as { tools: Array<{
name: string;
description?: string;
inputSchema: Record<string, unknown>;
}>};
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<MCPTool[]> {
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<MCPToolExecutionResult> {
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<void> {
const stopPromises = Array.from(this.servers.keys()).map(id => this.stopServer(id));
await Promise.all(stopPromises);
}
}

View File

@ -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<string, ChildProcess>();
private servers = new Map<string, MCPServer>();
private toolCache = new Map<string, MCPTool[]>();
private messageId = 1;
private pendingRequests = new Map<number, {
resolve: (value: unknown) => 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<MCPServer[]> {
return Array.from(this.servers.values());
}
async addServer(server: Omit<MCPServer, 'status'>): Promise<MCPServer> {
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<MCPServer>): Promise<MCPServer> {
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<void> {
await this.stopServer(serverId);
this.servers.delete(serverId);
this.toolCache.delete(serverId);
}
// ─────────────────────────────────────────────────────────────────────────────
// Process Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
private async startServer(serverId: string): Promise<void> {
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<void> {
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<unknown> {
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<void> {
// 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<void> {
const result = await this.sendRequest(serverId, 'tools/list') as { tools: Array<{
name: string;
description?: string;
inputSchema: Record<string, unknown>;
}>};
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<MCPTool[]> {
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<MCPToolExecutionResult> {
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<void> {
const stopPromises = Array.from(this.servers.keys()).map(id => this.stopServer(id));
await Promise.all(stopPromises);
}
}

View File

@ -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<MCPServer, 'status'>;
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<MCPServer>;
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<string> {
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);
});
}

View File

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

View File

@ -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';

View File

@ -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<MCPServer[]> {
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<MCPServer, 'status'>): Promise<MCPServer> {
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<MCPServer>;
}
/**
* Update an existing MCP server
* @param serverId - Server identifier
* @param updates - Partial server configuration to update
*/
static async updateServer(
serverId: string,
updates: Partial<MCPServer>
): Promise<MCPServer> {
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<MCPServer>;
}
/**
* Remove an MCP server
* @param serverId - Server identifier
*/
static async removeServer(serverId: string): Promise<void> {
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<MCPTool[]> {
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<MCPToolExecutionResult> {
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<MCPToolExecutionResult>;
}
}

View File

@ -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<ToolExecution[]>([]);
private toolExecutionCleanupTimeouts = new Map<string, number>();
// ─────────────────────────────────────────────────────────────────────────────
// 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<void> {
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<string, unknown> = {};
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;

View File

@ -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<MCPServer[]>([]);
tools = $state<MCPTool[]>([]);
isLoading = $state(false);
error = $state<string | null>(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<Omit<MCPServer, 'status'>>;
// 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<Omit<MCPServer, 'status'>> = [];
/**
* Initialize store by syncing with backend and loading persisted servers
*/
async initialize(): Promise<void> {
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<void> {
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<void> {
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<MCPServer, 'status'>): Promise<void> {
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<MCPServer>): Promise<void> {
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<void> {
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<string, unknown> } }> {
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();

View File

@ -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<string, unknown>;
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<string, unknown>;
};
}
export interface ApiChatCompletionToolCallFunctionDelta {

View File

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

View File

@ -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';

View File

@ -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<string, string>;
/** 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<string, unknown>;
}
/**
* 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[];
}

View File

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

View File

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

View File

@ -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 = `
<!--
@ -105,7 +107,7 @@ export default defineConfig({
}
}
},
plugins: [tailwindcss(), sveltekit(), devtoolsJson(), llamaCppBuildPlugin()],
plugins: [tailwindcss(), sveltekit(), devtoolsJson(), mcpPlugin(), llamaCppBuildPlugin()],
test: {
projects: [
{