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
JavaScript
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();