UNPKG

dddvchang-mcp-proxy

Version:

Smart MCP proxy with automatic JetBrains IDE discovery, WebSocket support, and intelligent connection naming

233 lines (232 loc) 7.56 kB
import dgram from 'dgram'; // Simple logging function to avoid circular imports const log = (message) => { const LOG_ENABLED = process.env.LOG_ENABLED === 'true'; if (LOG_ENABLED) { console.error(message); } }; /** * Cluster discovery client for automatically finding MCP server instances * Uses UDP multicast to discover nodes on the local network */ export class ClusterDiscoveryClient { socket = null; nodes = new Map(); discoveryCallbacks = []; nodeRemovedCallbacks = []; isDiscovering = false; cleanupInterval = null; // Multicast configuration matching Java plugin MULTICAST_GROUP = '224.0.0.251'; MULTICAST_PORT = 5353; NODE_TIMEOUT_MS = 30000; // 30 seconds CLEANUP_INTERVAL_MS = 10000; // 10 seconds constructor() { log('Cluster discovery client created'); } /** * Start discovering MCP server nodes on the network */ async startDiscovery() { if (this.isDiscovering) { log('Discovery already running'); return; } try { this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); this.socket.on('message', (msg, rinfo) => { this.handleDiscoveryMessage(msg, rinfo); }); this.socket.on('error', (err) => { log(`Discovery socket error: ${err.message}`); }); await new Promise((resolve, reject) => { this.socket.bind(this.MULTICAST_PORT, () => { try { this.socket.addMembership(this.MULTICAST_GROUP); log(`Started cluster discovery on ${this.MULTICAST_GROUP}:${this.MULTICAST_PORT}`); resolve(); } catch (err) { reject(err); } }); }); this.isDiscovering = true; this.startCleanupTimer(); } catch (error) { log(`Failed to start cluster discovery: ${error}`); throw error; } } /** * Stop cluster discovery */ async stopDiscovery() { if (!this.isDiscovering) { return; } this.isDiscovering = false; if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } if (this.socket) { try { this.socket.dropMembership(this.MULTICAST_GROUP); this.socket.close(); } catch (err) { log(`Error closing discovery socket: ${err}`); } this.socket = null; } this.nodes.clear(); log('Stopped cluster discovery'); } /** * Get all currently discovered nodes */ getDiscoveredNodes() { return Array.from(this.nodes.values()); } /** * Get healthy nodes only */ getHealthyNodes() { return this.getDiscoveredNodes().filter(node => node.health === 'HEALTHY' && (node.status === 'READY' || node.status === 'RUNNING')); } /** * Find the best node for a specific project path */ findNodeForProject(projectPath) { const healthyNodes = this.getHealthyNodes(); // First, try to find a node that has this project path const nodeWithProject = healthyNodes.find(node => node.projectPaths.includes(projectPath)); if (nodeWithProject) { return nodeWithProject; } // Fall back to any healthy node return healthyNodes.length > 0 ? healthyNodes[0] : null; } /** * Register callback for when new nodes are discovered */ onNodeDiscovered(callback) { this.discoveryCallbacks.push(callback); } /** * Register callback for when nodes are removed (timeout) */ onNodeRemoved(callback) { this.nodeRemovedCallbacks.push(callback); } /** * Handle incoming discovery messages */ handleDiscoveryMessage(msg, rinfo) { try { const message = JSON.parse(msg.toString()); if (message.type === 'NODE_ANNOUNCEMENT') { this.handleNodeAnnouncement(message.data, rinfo); } } catch (error) { log(`Failed to parse discovery message: ${error}`); } } /** * Handle node announcement messages */ handleNodeAnnouncement(data, rinfo) { const nodeId = data.nodeId; const now = new Date(); const node = { id: nodeId, host: rinfo.address, port: data.port || 63342, // Use IDE's built-in server port httpEndpoint: `http://${rinfo.address}:${data.port || 63342}/api`, websocketEndpoint: `ws://${rinfo.address}:${data.port || 63342}/api/mcp-ws/`, lastSeen: now, status: data.status || 'READY', projectPaths: data.projectPaths || [], health: this.calculateNodeHealth(data) }; const isNewNode = !this.nodes.has(nodeId); this.nodes.set(nodeId, node); if (isNewNode) { log(`Discovered new MCP server node: ${nodeId} at ${node.httpEndpoint}`); this.discoveryCallbacks.forEach(callback => { try { callback(node); } catch (err) { log(`Error in discovery callback: ${err}`); } }); } else { log(`Updated existing node: ${nodeId}`); } } /** * Calculate node health based on announcement data */ calculateNodeHealth(data) { // Simple health calculation based on error rate and response time const errorRate = data.errorRate || 0; const avgResponseTime = data.averageResponseTime || 0; if (errorRate > 10 || avgResponseTime > 5000) { return 'UNHEALTHY'; } else if (errorRate > 5 || avgResponseTime > 2000) { return 'DEGRADED'; } else { return 'HEALTHY'; } } /** * Start cleanup timer to remove stale nodes */ startCleanupTimer() { this.cleanupInterval = setInterval(() => { this.cleanupStaleNodes(); }, this.CLEANUP_INTERVAL_MS); } /** * Remove nodes that haven't been seen recently */ cleanupStaleNodes() { const now = new Date(); const staleNodes = []; for (const [nodeId, node] of this.nodes.entries()) { const timeSinceLastSeen = now.getTime() - node.lastSeen.getTime(); if (timeSinceLastSeen > this.NODE_TIMEOUT_MS) { staleNodes.push(nodeId); } } for (const nodeId of staleNodes) { this.nodes.delete(nodeId); log(`Removed stale node: ${nodeId}`); this.nodeRemovedCallbacks.forEach(callback => { try { callback(nodeId); } catch (err) { log(`Error in node removed callback: ${err}`); } }); } if (staleNodes.length > 0) { log(`Cleaned up ${staleNodes.length} stale nodes. Active nodes: ${this.nodes.size}`); } } } /** * Singleton instance for cluster discovery */ export const clusterDiscovery = new ClusterDiscoveryClient();