diff --git a/src/main/services/mcp.ts b/src/main/services/mcp.ts index 26bebd7bdb..4726b46449 100644 --- a/src/main/services/mcp.ts +++ b/src/main/services/mcp.ts @@ -5,6 +5,9 @@ import { v4 as uuidv4 } from 'uuid' import { windowService } from './WindowService' +/** + * Service for managing Model Context Protocol servers and tools + */ export default class MCPService extends EventEmitter { private servers: MCPServer[] = [] private activeServers: Map = new Map() @@ -14,22 +17,29 @@ export default class MCPService extends EventEmitter { private sseTransport: any private initialized = false private initPromise: Promise | null = null - private serversLoaded = false - private serversLoadedPromise: Promise | null = null - private serversLoadedResolve: (() => void) | null = null + + // Simplified server loading state management + private readyState = { + serversLoaded: false, + promise: null as Promise | null, + resolve: null as ((value: void) => void) | null + } constructor() { super() - - // Create a promise that will be resolved when servers are loaded from Redux - this.serversLoadedPromise = new Promise((resolve) => { - this.serversLoadedResolve = resolve - }) - - // Request servers from Redux on initialization + this.createServerLoadingPromise() this.requestServers() } + /** + * Create a promise that resolves when servers are loaded + */ + private createServerLoadingPromise(): void { + this.readyState.promise = new Promise((resolve) => { + this.readyState.resolve = resolve + }) + } + /** * Request server data from renderer process Redux */ @@ -44,68 +54,61 @@ export default class MCPService extends EventEmitter { } /** - * Set servers received from Redux + * Set servers received from Redux and trigger initialization if needed */ public setServers(servers: MCPServer[]): void { - log.info(`[MCP] Received ${servers.length} servers from Redux`) this.servers = servers - this.serversLoaded = true + log.info(`[MCP] Received ${servers.length} servers from Redux`) - // Resolve the promise to unlock initialization - if (this.serversLoadedResolve) { - this.serversLoadedResolve() - this.serversLoadedResolve = null + // Mark servers as loaded and resolve the waiting promise + if (!this.readyState.serversLoaded && this.readyState.resolve) { + this.readyState.serversLoaded = true + this.readyState.resolve() + this.readyState.resolve = null } // Initialize if not already initialized if (!this.initialized) { - this.init().catch((err) => { - log.error('[MCP] Failed to initialize MCP service:', err) - }) + this.init().catch(this.logError('Failed to initialize MCP service')) } } /** - * Get the current servers + * Initialize the MCP service if not already initialized */ - private getServers(): MCPServer[] { - return this.servers - } - - /** - * Wait for servers to be loaded from Redux - */ - private async waitForServers(): Promise { - if (!this.serversLoaded && this.serversLoadedPromise) { - log.info('[MCP] Waiting for servers data from Redux...') - await this.serversLoadedPromise - log.info('[MCP] Servers received, continuing initialization') - } - } - - public async init() { + public async init(): Promise { // If already initialized, return immediately if (this.initialized) return // If initialization is in progress, return that promise if (this.initPromise) return this.initPromise - // Create and store the initialization promise this.initPromise = (async () => { try { // Wait for servers to be loaded from Redux await this.waitForServers() log.info('[MCP] Starting initialization') - this.Client = await this.importClient() - this.stoioTransport = await this.importStdioClientTransport() - this.sseTransport = await this.importSSEClientTransport() - // Mark as initialized before loading servers to prevent recursive initialization + // Load SDK components in parallel for better performance + const [Client, StdioTransport, SSETransport] = await Promise.all([ + this.importClient(), + this.importStdioClientTransport(), + this.importSSEClientTransport() + ]) + + this.Client = Client + this.stoioTransport = StdioTransport + this.sseTransport = SSETransport + + // Mark as initialized before loading servers this.initialized = true - await this.load(this.getServers()) + // Load active servers + await this.loadActiveServers() log.info('[MCP] Initialization completed successfully') + + return } catch (err) { this.initialized = false // Reset flag on error log.error('[MCP] Failed to initialize:', err) @@ -118,6 +121,27 @@ export default class MCPService extends EventEmitter { return this.initPromise } + /** + * Wait for servers to be loaded from Redux + */ + private async waitForServers(): Promise { + if (!this.readyState.serversLoaded && this.readyState.promise) { + log.info('[MCP] Waiting for servers data from Redux...') + await this.readyState.promise + log.info('[MCP] Servers received, continuing initialization') + } + } + + /** + * Helper to create consistent error logging functions + */ + private logError(message: string) { + return (err: Error) => log.error(`[MCP] ${message}:`, err) + } + + /** + * Import the MCP client SDK + */ private async importClient() { try { const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') @@ -128,31 +152,43 @@ export default class MCPService extends EventEmitter { } } + /** + * Import the stdio transport + */ private async importStdioClientTransport() { try { const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js') return StdioClientTransport } catch (err) { - log.error('[MCP] Failed to import Transport:', err) + log.error('[MCP] Failed to import StdioTransport:', err) throw err } } + /** + * Import the SSE transport + */ private async importSSEClientTransport() { try { const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js') return SSEClientTransport } catch (err) { - log.error('[MCP] Failed to import Transport:', err) + log.error('[MCP] Failed to import SSETransport:', err) throw err } } + /** + * List all available MCP servers + */ public async listAvailableServices(): Promise { await this.ensureInitialized() - return this.getServers() + return this.servers } + /** + * Ensure the service is initialized before operations + */ private async ensureInitialized() { if (!this.initialized) { log.debug('[MCP] Ensuring initialization') @@ -160,90 +196,93 @@ export default class MCPService extends EventEmitter { } } + /** + * Add a new MCP server + */ public async addServer(server: MCPServer): Promise { await this.ensureInitialized() - try { - const servers = this.getServers() - if (servers.some((s) => s.name === server.name)) { - throw new Error(`Server with name ${server.name} already exists`) - } - servers.push(server) - this.notifyReduxServersChanged(servers) + // Check for duplicate name + if (this.servers.some((s) => s.name === server.name)) { + throw new Error(`Server with name ${server.name} already exists`) + } - if (server.isActive) { - await this.activate(server) - } - } catch (error) { - log.error('Failed to add MCP server:', error) - throw error + // Add to servers list + const updatedServers = [...this.servers, server] + this.servers = updatedServers + this.notifyReduxServersChanged(updatedServers) + + // Activate if needed + if (server.isActive) { + await this.activate(server).catch(this.logError(`Failed to activate server ${server.name}`)) } } + /** + * Update an existing MCP server + */ public async updateServer(server: MCPServer): Promise { await this.ensureInitialized() - try { - const servers = this.getServers() - const index = servers.findIndex((s) => s.name === server.name) - if (index === -1) { - throw new Error(`Server ${server.name} not found`) - } - - const wasActive = servers[index].isActive - if (wasActive && !server.isActive) { - await this.deactivate(server.name) - } else if (!wasActive && server.isActive) { - await this.activate(server) - } - - servers[index] = server - this.notifyReduxServersChanged(servers) - } catch (error) { - log.error('Failed to update MCP server:', error) - throw error + const index = this.servers.findIndex((s) => s.name === server.name) + if (index === -1) { + throw new Error(`Server ${server.name} not found`) } + + // Check activation status change + const wasActive = this.servers[index].isActive + if (wasActive && !server.isActive) { + await this.deactivate(server.name) + } else if (!wasActive && server.isActive) { + await this.activate(server) + } + + // Update servers list + const updatedServers = [...this.servers] + updatedServers[index] = server + this.servers = updatedServers + this.notifyReduxServersChanged(updatedServers) } + /** + * Delete an MCP server + */ public async deleteServer(serverName: string): Promise { await this.ensureInitialized() - try { - if (this.clients[serverName]) { - await this.deactivate(serverName) - } - const servers = this.getServers() - const filteredServers = servers.filter((s) => s.name !== serverName) - this.servers = filteredServers - this.notifyReduxServersChanged(filteredServers) - } catch (error) { - log.error('Failed to delete MCP server:', error) - throw error + // Deactivate if running + if (this.clients[serverName]) { + await this.deactivate(serverName) } + + // Update servers list + const filteredServers = this.servers.filter((s) => s.name !== serverName) + this.servers = filteredServers + this.notifyReduxServersChanged(filteredServers) } + /** + * Set a server's active state + */ public async setServerActive(params: { name: string; isActive: boolean }): Promise { await this.ensureInitialized() - try { - const { name, isActive } = params - const servers = this.getServers() - const server = servers.find((s) => s.name === name) - if (!server) { - throw new Error(`Server ${name} not found`) - } + const { name, isActive } = params + const server = this.servers.find((s) => s.name === name) - server.isActive = isActive - this.notifyReduxServersChanged(servers) + if (!server) { + throw new Error(`Server ${name} not found`) + } - if (isActive) { - await this.activate(server) - } else { - await this.deactivate(name) - } - } catch (error) { - log.error('Failed to set MCP server active status:', error) - throw error + // Update server status + server.isActive = isActive + this.notifyReduxServersChanged([...this.servers]) + + // Activate or deactivate as needed + if (isActive) { + await this.activate(server) + } else { + await this.deactivate(name) } } @@ -257,18 +296,24 @@ export default class MCPService extends EventEmitter { } } + /** + * Activate an MCP server + */ public async activate(server: MCPServer): Promise { await this.ensureInitialized() + + const { name, baseUrl, command, args, env } = server + + // Skip if already running + if (this.clients[name]) { + log.info(`[MCP] Server ${name} is already running`) + return + } + + let transport: any = null + try { - const { name, baseUrl, command, args, env } = server - - if (this.clients[name]) { - log.info(`[MCP] Server ${name} is already running`) - return - } - - let transport: any = null - + // Create appropriate transport based on configuration if (baseUrl) { transport = new this.sseTransport(new URL(baseUrl)) } else if (command) { @@ -292,141 +337,171 @@ export default class MCPService extends EventEmitter { throw new Error('Either baseUrl or command must be provided') } - const client = new this.Client( - { - name: name, - version: '1.0.0' - }, - { - capabilities: {} - } - ) + // Create and connect client + const client = new this.Client({ name, version: '1.0.0' }, { capabilities: {} }) await client.connect(transport) + + // Store client and server info this.clients[name] = client this.activeServers.set(name, { client, server }) log.info(`[MCP] Server ${name} started successfully`) this.emit('server-started', { name }) } catch (error) { - log.error('[MCP] Error activating server:', error) + log.error(`[MCP] Error activating server ${name}:`, error) throw error } } + /** + * Deactivate an MCP server + */ public async deactivate(name: string): Promise { await this.ensureInitialized() + + if (!this.clients[name]) { + log.warn(`[MCP] Server ${name} is not running`) + return + } + try { - if (this.clients[name]) { - log.info(`[MCP] Stopping server: ${name}`) - await this.clients[name].close() - delete this.clients[name] - this.activeServers.delete(name) - this.emit('server-stopped', { name }) - } else { - log.warn(`[MCP] Server ${name} is not running`) - } + log.info(`[MCP] Stopping server: ${name}`) + await this.clients[name].close() + delete this.clients[name] + this.activeServers.delete(name) + this.emit('server-stopped', { name }) } catch (error) { - log.error('[MCP] Error deactivating server:', error) + log.error(`[MCP] Error deactivating server ${name}:`, error) throw error } } + /** + * List available tools from active MCP servers + */ public async listTools(serverName?: string): Promise { await this.ensureInitialized() + try { + // If server name provided, list tools for that server only if (serverName) { - if (!this.clients[serverName]) { - throw new Error(`MCP Client ${serverName} not found`) - } - const { tools } = await this.clients[serverName].listTools() - return tools.map((tool: any) => { - tool.serverName = serverName - tool.id = 'f' + uuidv4().replace(/-/g, '') - return tool - }) - } else { - let allTools: MCPTool[] = [] - for (const clientName in this.clients) { - try { - const { tools } = await this.clients[clientName].listTools() - log.info(`[MCP] Tools for ${clientName}:`, tools) - allTools = allTools.concat( - tools.map((tool: MCPTool) => { - tool.serverName = clientName - tool.id = 'f' + uuidv4().replace(/-/g, '') - return tool - }) - ) - } catch (error) { - log.error(`[MCP] Error listing tools for ${clientName}:`, error) - } - } - log.info(`[MCP] Total tools listed: ${allTools.length}`) - return allTools + return await this.listToolsFromServer(serverName) } + + // Otherwise list tools from all active servers + let allTools: MCPTool[] = [] + + for (const clientName in this.clients) { + try { + const tools = await this.listToolsFromServer(clientName) + allTools = allTools.concat(tools) + } catch (error) { + this.logError(`[MCP] Error listing tools for ${clientName}`) + } + } + + log.info(`[MCP] Total tools listed: ${allTools.length}`) + return allTools } catch (error) { - log.error('[MCP] Error listing tools:', error) + this.logError('Error listing tools:') return [] } } + /** + * Helper method to list tools from a specific server + */ + private async listToolsFromServer(serverName: string): Promise { + if (!this.clients[serverName]) { + throw new Error(`MCP Client ${serverName} not found`) + } + + const { tools } = await this.clients[serverName].listTools() + return tools.map((tool: any) => ({ + ...tool, + serverName, + id: 'f' + uuidv4().replace(/-/g, '') + })) + } + + /** + * Call a tool on an MCP server + */ public async callTool(params: { client: string; name: string; args: any }): Promise { await this.ensureInitialized() - try { - const { client, name, args } = params - if (!this.clients[client]) { - throw new Error(`MCP Client ${client} not found`) - } - log.info('[MCP] Calling:', client, name, args) - const result = await this.clients[client].callTool({ + const { client, name, args } = params + + if (!this.clients[client]) { + throw new Error(`MCP Client ${client} not found`) + } + + log.info('[MCP] Calling:', client, name, args) + + try { + return await this.clients[client].callTool({ name, arguments: args }) - return result } catch (error) { - log.error(`[MCP] Error calling tool ${params.name} on ${params.client}:`, error) + log.error(`[MCP] Error calling tool ${name} on ${client}:`, error) throw error } } + /** + * Clean up all MCP resources + */ public async cleanup(): Promise { - try { - for (const name in this.clients) { - await this.deactivate(name).catch((err) => { + const clientNames = Object.keys(this.clients) + + if (clientNames.length === 0) { + log.info('[MCP] No active servers to clean up') + return + } + + log.info(`[MCP] Cleaning up ${clientNames.length} active servers`) + + // Deactivate all clients + await Promise.allSettled( + clientNames.map((name) => + this.deactivate(name).catch((err) => { log.error(`[MCP] Error during cleanup of ${name}:`, err) }) - } - this.clients = {} - this.activeServers.clear() - log.info('[MCP] All servers cleaned up') - } catch (error) { - log.error('[MCP] Failed to clean up servers:', error) - throw error - } + ) + ) + + this.clients = {} + this.activeServers.clear() + log.info('[MCP] All servers cleaned up') } - public async load(servers: MCPServer[]): Promise { - log.info(`[MCP] Loading ${servers.length} servers`) - - const activeServers = servers.filter((server) => server.isActive) + /** + * Load all active servers + */ + private async loadActiveServers(): Promise { + const activeServers = this.servers.filter((server) => server.isActive) if (activeServers.length === 0) { log.info('[MCP] No active servers to load') return } - for (const server of activeServers) { - log.info(`[MCP] Activating server: ${server.name}`) - try { - await this.activate(server) - log.info(`[MCP] Successfully activated server: ${server.name}`) - } catch (error) { - log.error(`[MCP] Failed to activate server ${server.name}:`, error) - this.emit('server-error', { name: server.name, error }) - } - } + log.info(`[MCP] Loading ${activeServers.length} active servers`) + + // Activate servers in parallel for better performance + await Promise.allSettled( + activeServers.map(async (server) => { + try { + await this.activate(server) + log.info(`[MCP] Successfully activated server: ${server.name}`) + } catch (error) { + this.logError(`Failed to activate server ${server.name}`) + this.emit('server-error', { name: server.name, error }) + } + }) + ) log.info(`[MCP] Loaded and activated ${Object.keys(this.clients).length} servers`) } diff --git a/src/renderer/src/providers/mcpToolUtils.ts b/src/renderer/src/providers/mcpToolUtils.ts index 068e3e018b..3156a41ef2 100644 --- a/src/renderer/src/providers/mcpToolUtils.ts +++ b/src/renderer/src/providers/mcpToolUtils.ts @@ -142,7 +142,7 @@ export function upsertMCPToolResponse( results.push(resp) } finally { onChunk({ - text: '', + text: '\n', mcpToolResponse: results }) }