import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { MCPServerConfig, MCPServerConnection, MCPClientState, MCPToolResult, } from "../types/mcp.js"; import { MCP_CLIENT_CONFIG, STORAGE_KEYS } from "../config/constants"; export class MCPClientService { private clients: Map = new Map(); private connections: Map = new Map(); private listeners: Array<(state: MCPClientState) => void> = []; constructor() { // Load saved server configurations from localStorage this.loadServerConfigs(); } // Add state change listener addStateListener(listener: (state: MCPClientState) => void) { this.listeners.push(listener); } // Remove state change listener removeStateListener(listener: (state: MCPClientState) => void) { const index = this.listeners.indexOf(listener); if (index > -1) { this.listeners.splice(index, 1); } } // Notify all listeners of state changes private notifyStateChange() { const state = this.getState(); this.listeners.forEach((listener) => listener(state)); } // Get current MCP client state getState(): MCPClientState { const servers: Record = {}; for (const [id, connection] of this.connections) { servers[id] = connection; } return { servers, isLoading: false, error: undefined, }; } // Load server configurations from localStorage private loadServerConfigs() { try { const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS); if (stored) { const configs: MCPServerConfig[] = JSON.parse(stored); configs.forEach((config) => { const connection: MCPServerConnection = { config, isConnected: false, tools: [], lastError: undefined, lastConnected: undefined, }; this.connections.set(config.id, connection); }); } } catch (error) { // Silently handle missing or corrupted config } } // Save server configurations to localStorage private saveServerConfigs() { try { const configs = Array.from(this.connections.values()).map( (conn) => conn.config ); localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(configs)); } catch (error) { // Handle storage errors gracefully throw new Error(`Failed to save server configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Add a new MCP server async addServer(config: MCPServerConfig): Promise { const connection: MCPServerConnection = { config, isConnected: false, tools: [], lastError: undefined, lastConnected: undefined, }; this.connections.set(config.id, connection); this.saveServerConfigs(); this.notifyStateChange(); // Auto-connect if enabled if (config.enabled) { await this.connectToServer(config.id); } } // Remove an MCP server async removeServer(serverId: string): Promise { // Disconnect first if connected await this.disconnectFromServer(serverId); // Remove from our maps this.connections.delete(serverId); this.clients.delete(serverId); this.saveServerConfigs(); this.notifyStateChange(); } // Connect to an MCP server async connectToServer(serverId: string): Promise { const connection = this.connections.get(serverId); if (!connection) { throw new Error(`Server ${serverId} not found`); } if (connection.isConnected) { return; // Already connected } try { // Create client const client = new Client( { name: MCP_CLIENT_CONFIG.NAME, version: MCP_CLIENT_CONFIG.VERSION, }, { capabilities: { tools: {}, }, } ); // Create transport based on config let transport; const url = new URL(connection.config.url); // Prepare headers for authentication const headers: Record = {}; if (connection.config.auth) { switch (connection.config.auth.type) { case "bearer": if (connection.config.auth.token) { headers[ "Authorization" ] = `Bearer ${connection.config.auth.token}`; } break; case "basic": if ( connection.config.auth.username && connection.config.auth.password ) { const credentials = btoa( `${connection.config.auth.username}:${connection.config.auth.password}` ); headers["Authorization"] = `Basic ${credentials}`; } break; case "oauth": if (connection.config.auth.token) { headers[ "Authorization" ] = `Bearer ${connection.config.auth.token}`; } break; } } switch (connection.config.transport) { case "websocket": { // Convert HTTP/HTTPS URLs to WS/WSS const wsUrl = new URL(connection.config.url); wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; transport = new WebSocketClientTransport(wsUrl); // Note: WebSocket auth headers would need to be passed differently // For now, auth is only supported on HTTP-based transports break; } case "streamable-http": transport = new StreamableHTTPClientTransport(url, { requestInit: Object.keys(headers).length > 0 ? { headers } : undefined, }); break; case "sse": transport = new SSEClientTransport(url, { requestInit: Object.keys(headers).length > 0 ? { headers } : undefined, }); break; default: throw new Error( `Unsupported transport: ${connection.config.transport}` ); } // Set up error handling client.onerror = (error) => { connection.lastError = error.message; connection.isConnected = false; this.notifyStateChange(); }; // Connect to the server await client.connect(transport); // List available tools const toolsResult = await client.listTools(); // Update connection state connection.isConnected = true; connection.tools = toolsResult.tools; connection.lastError = undefined; connection.lastConnected = new Date(); // Store client reference this.clients.set(serverId, client); this.notifyStateChange(); } catch (error) { connection.isConnected = false; connection.lastError = error instanceof Error ? error.message : "Connection failed"; this.notifyStateChange(); throw error; } } // Disconnect from an MCP server async disconnectFromServer(serverId: string): Promise { const client = this.clients.get(serverId); const connection = this.connections.get(serverId); if (client) { try { await client.close(); } catch (error) { // Handle disconnect error silently } this.clients.delete(serverId); } if (connection) { connection.isConnected = false; connection.tools = []; this.notifyStateChange(); } } // Get all tools from all connected servers getAllTools(): Tool[] { const allTools: Tool[] = []; for (const connection of this.connections.values()) { if (connection.isConnected && connection.config.enabled) { allTools.push(...connection.tools); } } return allTools; } // Call a tool on an MCP server async callTool( serverId: string, toolName: string, args: Record ): Promise { const client = this.clients.get(serverId); const connection = this.connections.get(serverId); if (!client || !connection?.isConnected) { throw new Error(`Not connected to server ${serverId}`); } try { const result = await client.callTool({ name: toolName, arguments: args, }); return { content: Array.isArray(result.content) ? result.content : [], isError: Boolean(result.isError), }; } catch (error) { throw new Error(`Tool execution failed (${toolName}): ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Test connection to a server without saving it async testConnection(config: MCPServerConfig): Promise { try { const client = new Client( { name: MCP_CLIENT_CONFIG.TEST_CLIENT_NAME, version: MCP_CLIENT_CONFIG.VERSION, }, { capabilities: { tools: {}, }, } ); let transport; const url = new URL(config.url); switch (config.transport) { case "websocket": { const wsUrl = new URL(config.url); wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; transport = new WebSocketClientTransport(wsUrl); break; } case "streamable-http": transport = new StreamableHTTPClientTransport(url); break; case "sse": transport = new SSEClientTransport(url); break; default: throw new Error(`Unsupported transport: ${config.transport}`); } await client.connect(transport); await client.close(); return true; } catch (error) { throw new Error(`Connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Connect to all enabled servers async connectAll(): Promise { const promises = Array.from(this.connections.entries()) .filter( ([, connection]) => connection.config.enabled && !connection.isConnected ) .map(([serverId]) => this.connectToServer(serverId).catch(() => { // Handle auto-connection error silently }) ); await Promise.all(promises); } // Disconnect from all servers async disconnectAll(): Promise { const promises = Array.from(this.connections.keys()).map((serverId) => this.disconnectFromServer(serverId) ); await Promise.all(promises); } }