llama.cpp/tools/server/webui/src/lib/stores/mcp.svelte.ts

502 lines
16 KiB
TypeScript

import { browser } from '$app/environment';
import {
MCPHostManager,
type OpenAIToolDefinition,
type ServerStatus
} from '$lib/mcp/host-manager';
import type { ToolExecutionResult } from '$lib/mcp/server-connection';
import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/config/mcp';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import type { MCPToolCall } from '$lib/types/mcp';
import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { MCPClient } from '$lib/mcp';
import { detectMcpTransportFromUrl } from '$lib/utils/mcp';
// ─────────────────────────────────────────────────────────────────────────────
// Health Check Types
// ─────────────────────────────────────────────────────────────────────────────
export type HealthCheckState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; tools: { name: string; description?: string }[] };
/**
* mcpStore - Reactive store for MCP (Model Context Protocol) host management
*
* This store manages:
* - MCPHostManager lifecycle (initialization, shutdown)
* - Connection state tracking for multiple MCP servers
* - Aggregated tools from all connected MCP servers
* - Error handling for MCP operations
*
* **Architecture & Relationships:**
* - **MCPHostManager**: Coordinates multiple MCPServerConnection instances
* - **MCPServerConnection**: Single SDK Client wrapper per server
* - **mcpStore** (this class): Reactive Svelte store for MCP state
* - **agenticStore**: Uses mcpStore for tool execution in agentic loop
* - **settingsStore**: Provides MCP server configuration
*
* **Key Features:**
* - Reactive state with Svelte 5 runes ($state, $derived)
* - Automatic reinitialization on config changes
* - Aggregates tools from multiple servers
* - Routes tool calls to appropriate server automatically
* - Graceful error handling with fallback to standard chat
*/
class MCPStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
private _hostManager = $state<MCPHostManager | null>(null);
private _isInitializing = $state(false);
private _error = $state<string | null>(null);
private _configSignature = $state<string | null>(null);
private _initPromise: Promise<MCPHostManager | undefined> | null = null;
// Health check state (in-memory only, not persisted)
private _healthChecks = $state<Record<string, HealthCheckState>>({});
// ─────────────────────────────────────────────────────────────────────────────
// Computed Getters
// ─────────────────────────────────────────────────────────────────────────────
get hostManager(): MCPHostManager | null {
return this._hostManager;
}
get isInitializing(): boolean {
return this._isInitializing;
}
get isInitialized(): boolean {
return this._hostManager?.isInitialized ?? false;
}
get error(): string | null {
return this._error;
}
/**
* Check if MCP is enabled (has configured servers)
*/
get isEnabled(): boolean {
const mcpConfig = buildMcpClientConfig(config());
return (
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
);
}
/**
* Get list of available tool names (aggregated from all servers)
*/
get availableTools(): string[] {
return this._hostManager?.getToolNames() ?? [];
}
/**
* Get number of connected servers
*/
get connectedServerCount(): number {
return this._hostManager?.connectedServerCount ?? 0;
}
/**
* Get names of connected servers
*/
get connectedServerNames(): string[] {
return this._hostManager?.connectedServerNames ?? [];
}
/**
* Get total tool count
*/
get toolCount(): number {
return this._hostManager?.toolCount ?? 0;
}
/**
* Get tool definitions for LLM (OpenAI function calling format)
*/
getToolDefinitions(): OpenAIToolDefinition[] {
return this._hostManager?.getToolDefinitionsForLLM() ?? [];
}
/**
* Get status of all servers
*/
getServersStatus(): ServerStatus[] {
return this._hostManager?.getServersStatus() ?? [];
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
/**
* Ensure MCP host manager is initialized with current config.
* Returns the host manager if successful, undefined otherwise.
* Handles config changes by reinitializing as needed.
* @param perChatOverrides - Optional per-chat MCP server overrides
*/
async ensureInitialized(
perChatOverrides?: McpServerOverride[]
): Promise<MCPHostManager | undefined> {
if (!browser) return undefined;
const mcpConfig = buildMcpClientConfig(config(), perChatOverrides);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
// No config - shutdown if needed
if (!signature) {
await this.shutdown();
return undefined;
}
// Already initialized with correct config
if (this._hostManager?.isInitialized && this._configSignature === signature) {
return this._hostManager;
}
// Init in progress with correct config - wait for it
if (this._initPromise && this._configSignature === signature) {
return this._initPromise;
}
// Config changed or first init - shutdown old manager first
if (this._hostManager || this._initPromise) {
await this.shutdown();
}
// Initialize new host manager
return this.initialize(signature, mcpConfig!);
}
/**
* Initialize MCP host manager with given config
*/
private async initialize(
signature: string,
mcpConfig: NonNullable<ReturnType<typeof buildMcpClientConfig>>
): Promise<MCPHostManager | undefined> {
this._isInitializing = true;
this._error = null;
this._configSignature = signature;
const hostManager = new MCPHostManager();
this._initPromise = hostManager
.initialize({
servers: mcpConfig.servers,
clientInfo: mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo,
capabilities: mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities
})
.then(() => {
// Check if config changed during initialization
if (this._configSignature !== signature) {
void hostManager.shutdown().catch((err) => {
console.error('[MCP Store] Failed to shutdown stale host manager:', err);
});
return undefined;
}
this._hostManager = hostManager;
this._isInitializing = false;
const toolNames = hostManager.getToolNames();
const serverNames = hostManager.connectedServerNames;
console.log(
`[MCP Store] Initialized: ${serverNames.length} servers, ${toolNames.length} tools`
);
console.log(`[MCP Store] Servers: ${serverNames.join(', ')}`);
console.log(`[MCP Store] Tools: ${toolNames.join(', ')}`);
return hostManager;
})
.catch((error) => {
console.error('[MCP Store] Initialization failed:', error);
this._error = error instanceof Error ? error.message : String(error);
this._isInitializing = false;
void hostManager.shutdown().catch((err) => {
console.error('[MCP Store] Failed to shutdown after error:', err);
});
return undefined;
})
.finally(() => {
if (this._configSignature === signature) {
this._initPromise = null;
}
});
return this._initPromise;
}
/**
* Shutdown MCP host manager and clear state
*/
async shutdown(): Promise<void> {
// Wait for any pending initialization
if (this._initPromise) {
await this._initPromise.catch(() => {});
this._initPromise = null;
}
if (this._hostManager) {
const managerToShutdown = this._hostManager;
this._hostManager = null;
this._configSignature = null;
this._error = null;
try {
await managerToShutdown.shutdown();
console.log('[MCP Store] Host manager shutdown complete');
} catch (error) {
console.error('[MCP Store] Shutdown error:', error);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool Execution
// ─────────────────────────────────────────────────────────────────────────────
/**
* Execute a tool call via MCP host manager.
* Automatically routes to the appropriate server.
*/
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
if (!this._hostManager) {
throw new Error('MCP host manager not initialized');
}
return this._hostManager.executeTool(toolCall, signal);
}
/**
* Execute a tool by name with arguments.
* Simpler interface for direct tool calls.
*/
async executeToolByName(
toolName: string,
args: Record<string, unknown>,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
if (!this._hostManager) {
throw new Error('MCP host manager not initialized');
}
return this._hostManager.executeToolByName(toolName, args, signal);
}
/**
* Check if a tool exists
*/
hasTool(toolName: string): boolean {
return this._hostManager?.hasTool(toolName) ?? false;
}
/**
* Get which server provides a specific tool
*/
getToolServer(toolName: string): string | undefined {
return this._hostManager?.getToolServer(toolName);
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Clear error state
*/
clearError(): void {
this._error = null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Health Check (Settings UI)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get health check state for a specific server
*/
getHealthCheckState(serverId: string): HealthCheckState {
return this._healthChecks[serverId] ?? { status: 'idle' };
}
/**
* Set health check state for a specific server
*/
private setHealthCheckState(serverId: string, state: HealthCheckState): void {
this._healthChecks = { ...this._healthChecks, [serverId]: state };
}
/**
* Check if health check has been performed for a server
*/
hasHealthCheck(serverId: string): boolean {
return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle';
}
/**
* Parse custom headers from JSON string
*/
private parseHeaders(headersJson?: string): Record<string, string> | undefined {
if (!headersJson?.trim()) return undefined;
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as Record<string, string>;
}
} catch {
console.warn('[MCP Store] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
/**
* Run health check for a specific server
*/
async runHealthCheck(server: {
id: string;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}): Promise<void> {
const trimmedUrl = server.url.trim();
if (!trimmedUrl) {
this.setHealthCheckState(server.id, {
status: 'error',
message: 'Please enter a server URL first.'
});
return;
}
this.setHealthCheckState(server.id, { status: 'loading' });
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
const headers = this.parseHeaders(server.headers);
const mcpClient = new MCPClient({
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
capabilities: DEFAULT_MCP_CONFIG.capabilities,
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
requestTimeoutMs: timeoutMs,
servers: {
[server.id]: {
url: trimmedUrl,
transport: detectMcpTransportFromUrl(trimmedUrl),
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
requestTimeoutMs: timeoutMs,
headers
}
}
});
try {
await mcpClient.initialize();
const tools = (await mcpClient.getToolsDefinition()).map((tool) => ({
name: tool.function.name,
description: tool.function.description
}));
this.setHealthCheckState(server.id, { status: 'success', tools });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
this.setHealthCheckState(server.id, { status: 'error', message });
} finally {
try {
await mcpClient.shutdown();
} catch (shutdownError) {
console.warn('[MCP Store] Failed to cleanly shutdown health check client', shutdownError);
}
}
}
/**
* Clear health check state for a specific server
*/
clearHealthCheck(serverId: string): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [serverId]: _removed, ...rest } = this._healthChecks;
this._healthChecks = rest;
}
/**
* Clear all health check states
*/
clearAllHealthChecks(): void {
this._healthChecks = {};
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Singleton Instance & Exports
// ─────────────────────────────────────────────────────────────────────────────
export const mcpStore = new MCPStore();
// Reactive exports for components
export function mcpHostManager() {
return mcpStore.hostManager;
}
export function mcpIsInitializing() {
return mcpStore.isInitializing;
}
export function mcpIsInitialized() {
return mcpStore.isInitialized;
}
export function mcpError() {
return mcpStore.error;
}
export function mcpIsEnabled() {
return mcpStore.isEnabled;
}
export function mcpAvailableTools() {
return mcpStore.availableTools;
}
export function mcpConnectedServerCount() {
return mcpStore.connectedServerCount;
}
export function mcpConnectedServerNames() {
return mcpStore.connectedServerNames;
}
export function mcpToolCount() {
return mcpStore.toolCount;
}
// Health check exports
export function mcpGetHealthCheckState(serverId: string) {
return mcpStore.getHealthCheckState(serverId);
}
export function mcpHasHealthCheck(serverId: string) {
return mcpStore.hasHealthCheck(serverId);
}
export async function mcpRunHealthCheck(server: {
id: string;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}) {
return mcpStore.runHealthCheck(server);
}
export function mcpClearHealthCheck(serverId: string) {
return mcpStore.clearHealthCheck(serverId);
}