/** * MCPHostManager - Multi-server MCP connection aggregator * * Implements the "Host" role in MCP architecture, coordinating multiple server * connections and providing a unified interface for tool operations. * * **Architecture & Relationships:** * - **MCPHostManager** (this class): Host-level coordination layer * - Coordinates multiple Client instances (MCPServerConnection) * - Aggregates tools from all connected servers * - Routes tool calls to the appropriate server * - Manages lifecycle of all connections * * - **MCPServerConnection**: Individual server connection wrapper * - **agenticStore**: Uses MCPHostManager for tool execution in agentic loops * * **Key Responsibilities:** * - Parallel server initialization and shutdown * - Tool name conflict detection and resolution * - OpenAI-compatible tool definition generation * - Automatic tool-to-server routing */ import { MCPServerConnection, type ToolExecutionResult } from './server-connection'; import type { MCPClientConfig, MCPToolCall, ClientCapabilities, Implementation } from '$lib/types/mcp'; import { MCPError } from '$lib/types/mcp'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; export interface MCPHostManagerConfig { /** Server configurations keyed by server name */ servers: MCPClientConfig['servers']; /** Client info to advertise to all servers */ clientInfo?: Implementation; /** Default capabilities to advertise */ capabilities?: ClientCapabilities; } export interface OpenAIToolDefinition { type: 'function'; function: { name: string; description?: string; parameters: Record; }; } export interface ServerStatus { name: string; isConnected: boolean; toolCount: number; error?: string; } /** * Manages multiple MCP server connections and provides unified tool access. */ export class MCPHostManager { private connections = new Map(); private toolsIndex = new Map(); // toolName → serverName private _isInitialized = false; private _initializationError: Error | null = null; // ───────────────────────────────────────────────────────────────────────── // Lifecycle // ───────────────────────────────────────────────────────────────────────── async initialize(config: MCPHostManagerConfig): Promise { console.log('[MCPHost] Starting initialization...'); // Clean up previous connections await this.shutdown(); const serverEntries = Object.entries(config.servers); if (serverEntries.length === 0) { console.log('[MCPHost] No servers configured'); this._isInitialized = true; return; } // Connect to each server in parallel const connectionPromises = serverEntries.map(async ([name, serverConfig]) => { try { const connection = new MCPServerConnection({ name, server: serverConfig, clientInfo: config.clientInfo, capabilities: config.capabilities }); await connection.connect(); return { name, connection, success: true, error: null }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`[MCPHost] Failed to connect to ${name}:`, errorMessage); return { name, connection: null, success: false, error: errorMessage }; } }); const results = await Promise.all(connectionPromises); // Store successful connections for (const result of results) { if (result.success && result.connection) { this.connections.set(result.name, result.connection); } } // Build tools index this.rebuildToolsIndex(); const successCount = this.connections.size; const totalCount = serverEntries.length; if (successCount === 0 && totalCount > 0) { this._initializationError = new Error('All MCP server connections failed'); throw this._initializationError; } this._isInitialized = true; this._initializationError = null; console.log( `[MCPHost] Initialization complete: ${successCount}/${totalCount} servers connected, ` + `${this.toolsIndex.size} tools available` ); } async shutdown(): Promise { if (this.connections.size === 0) { return; } console.log(`[MCPHost] Shutting down ${this.connections.size} connections...`); const shutdownPromises = Array.from(this.connections.values()).map((conn) => conn.disconnect().catch((error) => { console.warn(`[MCPHost] Error disconnecting ${conn.serverName}:`, error); }) ); await Promise.all(shutdownPromises); this.connections.clear(); this.toolsIndex.clear(); this._isInitialized = false; console.log('[MCPHost] Shutdown complete'); } private rebuildToolsIndex(): void { this.toolsIndex.clear(); for (const [serverName, connection] of this.connections) { for (const tool of connection.tools) { // Check for name conflicts if (this.toolsIndex.has(tool.name)) { console.warn( `[MCPHost] Tool name conflict: "${tool.name}" exists in ` + `"${this.toolsIndex.get(tool.name)}" and "${serverName}". ` + `Using tool from "${serverName}".` ); } this.toolsIndex.set(tool.name, serverName); } } } // ───────────────────────────────────────────────────────────────────────── // Tool Aggregation // ───────────────────────────────────────────────────────────────────────── /** * Returns ALL tools from ALL connected servers. * This is what we send to LLM as available tools. */ getAllTools(): Tool[] { const allTools: Tool[] = []; for (const connection of this.connections.values()) { allTools.push(...connection.tools); } return allTools; } /** * Normalize JSON Schema properties to ensure all have explicit types. * Infers type from default value if missing - fixes compatibility with * llama.cpp which requires explicit types in tool schemas. */ private normalizeSchemaProperties(schema: Record): Record { if (!schema || typeof schema !== 'object') return schema; const normalized = { ...schema }; // Process properties object if (normalized.properties && typeof normalized.properties === 'object') { const props = normalized.properties as Record>; const normalizedProps: Record> = {}; for (const [key, prop] of Object.entries(props)) { if (!prop || typeof prop !== 'object') { normalizedProps[key] = prop; continue; } const normalizedProp = { ...prop }; // Infer type from default if missing if (!normalizedProp.type && normalizedProp.default !== undefined) { const defaultVal = normalizedProp.default; if (typeof defaultVal === 'string') { normalizedProp.type = 'string'; } else if (typeof defaultVal === 'number') { normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number'; } else if (typeof defaultVal === 'boolean') { normalizedProp.type = 'boolean'; } else if (Array.isArray(defaultVal)) { normalizedProp.type = 'array'; } else if (typeof defaultVal === 'object' && defaultVal !== null) { normalizedProp.type = 'object'; } } // Recursively normalize nested schemas if (normalizedProp.properties) { Object.assign( normalizedProp, this.normalizeSchemaProperties(normalizedProp as Record) ); } // Normalize items in array schemas if (normalizedProp.items && typeof normalizedProp.items === 'object') { normalizedProp.items = this.normalizeSchemaProperties( normalizedProp.items as Record ); } normalizedProps[key] = normalizedProp; } normalized.properties = normalizedProps; } return normalized; } /** * Returns tools in OpenAI function calling format. * Ready to be sent to /v1/chat/completions API. */ getToolDefinitionsForLLM(): OpenAIToolDefinition[] { return this.getAllTools().map((tool) => { const rawSchema = (tool.inputSchema as Record) ?? { type: 'object', properties: {}, required: [] }; // Normalize schema to fix missing types const normalizedSchema = this.normalizeSchemaProperties(rawSchema); return { type: 'function' as const, function: { name: tool.name, description: tool.description, parameters: normalizedSchema } }; }); } /** * Returns names of all available tools. */ getToolNames(): string[] { return Array.from(this.toolsIndex.keys()); } /** * Check if a tool exists. */ hasTool(toolName: string): boolean { return this.toolsIndex.has(toolName); } /** * Get which server provides a specific tool. */ getToolServer(toolName: string): string | undefined { return this.toolsIndex.get(toolName); } // ───────────────────────────────────────────────────────────────────────── // Tool Execution // ───────────────────────────────────────────────────────────────────────── /** * Execute a tool call, automatically routing to the appropriate server. * Accepts the OpenAI-style tool call format. */ async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise { const toolName = toolCall.function.name; // Find which server handles this tool const serverName = this.toolsIndex.get(toolName); if (!serverName) { throw new MCPError(`Unknown tool: ${toolName}`, -32601); } const connection = this.connections.get(serverName); if (!connection) { throw new MCPError(`Server "${serverName}" is not connected`, -32000); } // Parse arguments const args = this.parseToolArguments(toolCall.function.arguments); // Delegate to the appropriate server return connection.callTool({ name: toolName, arguments: args }, signal); } /** * Execute a tool by name with arguments object. * Simpler interface for direct tool calls. */ async executeToolByName( toolName: string, args: Record, signal?: AbortSignal ): Promise { const serverName = this.toolsIndex.get(toolName); if (!serverName) { throw new MCPError(`Unknown tool: ${toolName}`, -32601); } const connection = this.connections.get(serverName); if (!connection) { throw new MCPError(`Server "${serverName}" is not connected`, -32000); } return connection.callTool({ name: toolName, arguments: args }, signal); } private parseToolArguments(args: string | Record): Record { if (typeof args === 'string') { const trimmed = args.trim(); if (trimmed === '') { return {}; } try { const parsed = JSON.parse(trimmed); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new MCPError( `Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`, -32602 ); } return parsed as Record; } catch (error) { if (error instanceof MCPError) { throw error; } throw new MCPError( `Failed to parse tool arguments as JSON: ${(error as Error).message}`, -32700 ); } } if (typeof args === 'object' && args !== null && !Array.isArray(args)) { return args; } throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602); } // ───────────────────────────────────────────────────────────────────────── // State // ───────────────────────────────────────────────────────────────────────── get isInitialized(): boolean { return this._isInitialized; } get initializationError(): Error | null { return this._initializationError; } get connectedServerCount(): number { return this.connections.size; } get connectedServerNames(): string[] { return Array.from(this.connections.keys()); } get toolCount(): number { return this.toolsIndex.size; } /** * Get status of all configured servers. */ getServersStatus(): ServerStatus[] { const statuses: ServerStatus[] = []; for (const [name, connection] of this.connections) { statuses.push({ name, isConnected: connection.isConnected, toolCount: connection.tools.length, error: connection.lastError?.message }); } return statuses; } /** * Get a specific server connection (for advanced use cases). */ getServerConnection(name: string): MCPServerConnection | undefined { return this.connections.get(name); } }