UNPKG

dddvchang-mcp-proxy

Version:

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

734 lines (729 loc) 28.2 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { WebSocketMCPClient } from './websocket-client.js'; import { clusterDiscovery } from './cluster-discovery.js'; import { clusterWebSocketManager } from './cluster-websocket-manager.js'; // Logging is enabled only if LOG_ENABLED environment variable is set to 'true' const LOG_ENABLED = process.env.LOG_ENABLED === 'true'; const HOST = process.env.HOST ?? "127.0.0.1"; export function log(...args) { if (LOG_ENABLED) { console.error(...args); } } /** * Globally store the cached IDE endpoint and WebSocket client. * We'll update this once at the beginning and every 10 seconds. */ let cachedEndpoint = null; let wsClient = null; let preferWebSocket = true; // Whether to prefer WebSocket over HTTP let useClusterDiscovery = true; // Always enable cluster discovery by default let clusterMode = false; // Track if we're in cluster mode let availableIDEs = new Map(); // Store discovered IDEs /** * If you need to remember the last known response from /mcp/list_tools, store it here. * That way, you won't re-check it every single time a new request comes in. */ let previousResponse = null; /** * Helper to send the "tools changed" notification. */ function sendToolsChanged() { try { log("Sending tools changed notification."); server.notification({ method: "notifications/tools/list_changed" }); } catch (error) { log("Error sending tools changed notification:", error); } } /** * Extract project path from tool arguments for cluster routing */ function extractProjectPath(args) { // Common patterns for project path extraction if (args.projectPath) return args.projectPath; if (args.pathInProject) { // For file operations, we might need to derive the project path return undefined; // Let cluster manager decide } // Extract project names from text content for intelligent routing if (args.text || args.searchText || args.query) { const text = args.text || args.searchText || args.query; return extractProjectFromText(text); } return undefined; } /** * Extract project name from natural language text * Looks for patterns like "xxx项目", "xxx project", or common project names */ function extractProjectFromText(text) { if (!text || typeof text !== 'string') return undefined; // Common patterns for project references in Chinese/English const projectPatterns = [ /(\w+)项目/g, // "xxx项目" /(\w+)\s*project/gi, // "xxx project" /查看\s*(\w+)/g, // "查看 xxx" /修改\s*(\w+)/g, // "修改 xxx" /参考\s*(\w+)/g, // "参考 xxx" /打开\s*(\w+)/g, // "打开 xxx" /编辑\s*(\w+)/g, // "编辑 xxx" ]; for (const pattern of projectPatterns) { const matches = text.matchAll(pattern); for (const match of matches) { const projectName = match[1]; if (projectName && projectName.length > 1) { log(`Extracted project name from text: ${projectName}`); return projectName; } } } return undefined; } /** * Get IDE type based on port number patterns */ function getIDETypeFromPort(port) { // Common JetBrains IDE port patterns const portRanges = { 'WebStorm': [63330, 63332], 'IntelliJ IDEA': [63340, 63350], 'PyCharm': [63350, 63360], 'PhpStorm': [63360, 63370], 'CLion': [63370, 63380], 'DataGrip': [63380, 63390], 'GoLand': [63390, 63400], 'Rider': [63400, 63410], 'Android Studio': [63410, 63420], 'RubyMine': [63420, 63430] }; for (const [ide, range] of Object.entries(portRanges)) { if (port >= range[0] && port <= range[1]) { return ide; } } // Fallback: try to detect based on known running processes return `JetBrains IDE (port ${port})`; } /** * Switch to a specific IDE by name */ async function switchToIDE(ideName) { if (!clusterMode || availableIDEs.size === 0) { return "No IDEs discovered. Please ensure IDEs are running with MCP Server Plugin."; } const lowerName = ideName.toLowerCase(); let targetNode; // Find IDE by name (case-insensitive partial match) for (const [id, node] of availableIDEs) { const ideType = getIDETypeFromPort(node.port).toLowerCase(); if (ideType.includes(lowerName)) { targetNode = node; break; } } if (!targetNode) { const availableIDEsList = Array.from(availableIDEs.values()) .map(node => getIDETypeFromPort(node.port)) .join(', '); return `IDE '${ideName}' not found. Available IDEs: ${availableIDEsList}`; } try { // Switch cluster manager to target this specific IDE await clusterWebSocketManager.setPreferredNode(targetNode.id); // Update cached endpoint for backward compatibility cachedEndpoint = targetNode.httpEndpoint; log(`Switched to ${getIDETypeFromPort(targetNode.port)} at ${targetNode.host}:${targetNode.port}`); return `Successfully connected to ${getIDETypeFromPort(targetNode.port)} at ${targetNode.host}:${targetNode.port}`; } catch (error) { log(`Failed to switch to ${ideName}:`, error); return `Failed to connect to ${ideName}: ${error instanceof Error ? error.message : 'Unknown error'}`; } } /** * List all available IDEs */ function listAvailableIDEs() { if (!clusterMode || availableIDEs.size === 0) { return "No IDEs discovered. Please ensure IDEs are running with MCP Server Plugin."; } const ideList = Array.from(availableIDEs.values()) .map(node => `- ${getIDETypeFromPort(node.port)} at ${node.host}:${node.port}`) .join('\n'); return `Available IDEs (${availableIDEs.size}):\n${ideList}`; } /** * Test if /mcp/list_tools is responding on a given endpoint * * @returns true if working, false otherwise */ async function testListTools(endpoint) { log(`Sending test request to ${endpoint}/mcp/list_tools`); try { const res = await fetch(`${endpoint}/mcp/list_tools`); if (!res.ok) { log(`Test request to ${endpoint}/mcp/list_tools failed with status ${res.status}`); return false; } const currentResponse = await res.text(); log(`Received response from ${endpoint}/mcp/list_tools: ${currentResponse.substring(0, 100)}...`); // If the response changed from last time, notify if (previousResponse !== null && previousResponse !== currentResponse) { log("Response has changed since the last check."); sendToolsChanged(); } previousResponse = currentResponse; return true; } catch (error) { log(`Error during testListTools for endpoint ${endpoint}:`, error); return false; } } /** * Finds and returns a working IDE endpoint using IPv4 by: * 1. Checking process.env.IDE_PORT, or * 2. Scanning ports 63330-63340 * * Throws if none found. */ async function findWorkingIDEEndpoint() { log("Attempting to find a working IDE endpoint..."); // 1. If user specified a port, just use that if (process.env.IDE_PORT) { log(`IDE_PORT is set to ${process.env.IDE_PORT}. Testing this port.`); const testEndpoint = `http://${HOST}:${process.env.IDE_PORT}/api`; if (await testListTools(testEndpoint)) { log(`IDE_PORT ${process.env.IDE_PORT} is working.`); return testEndpoint; } else { log(`Specified IDE_PORT=${process.env.IDE_PORT} but it is not responding correctly.`); throw new Error(`Specified IDE_PORT=${process.env.IDE_PORT} but it is not responding correctly.`); } } // 2. Reuse existing endpoint if it's still working if (cachedEndpoint != null && await testListTools(cachedEndpoint)) { log('Using cached endpoint, it\'s still working'); return cachedEndpoint; } // 3. Otherwise, scan a range of ports for (let port = 63330; port <= 63340; port++) { const candidateEndpoint = `http://${HOST}:${port}/api`; log(`Testing port ${port}...`); const isWorking = await testListTools(candidateEndpoint); if (isWorking) { log(`Found working IDE endpoint at ${candidateEndpoint}`); return candidateEndpoint; } else { log(`Port ${port} is not responding correctly.`); } } // If we reach here, no port was found previousResponse = ""; log("No working IDE endpoint found in range 63330-63340"); throw new Error("No working IDE endpoint found in range 63330-63340"); } /** * Test if WebSocket endpoint is working */ async function testWebSocketEndpoint(endpoint) { const wsUrl = endpoint.replace('http://', 'ws://').replace('/api', '/api/mcp-ws/'); log(`Testing WebSocket endpoint: ${wsUrl}`); try { const testClient = new WebSocketMCPClient(); const success = await testClient.testConnection(wsUrl); if (success) { log(`WebSocket endpoint ${wsUrl} is working`); } else { log(`WebSocket endpoint ${wsUrl} is not working`); } return success; } catch (error) { log(`Error testing WebSocket endpoint ${wsUrl}:`, error); return false; } } /** * Updates the cached endpoint and WebSocket client by finding working endpoints. * This runs once at startup and then once every 10 seconds in runServer(). */ async function updateIDEEndpoint() { try { const newEndpoint = await findWorkingIDEEndpoint(); // Test WebSocket support if endpoint changed or we don't have a WebSocket client if (newEndpoint !== cachedEndpoint || !wsClient) { const wsSupported = await testWebSocketEndpoint(newEndpoint); if (wsSupported && preferWebSocket) { // Initialize WebSocket client try { if (wsClient) { wsClient.disconnect(); } wsClient = new WebSocketMCPClient(); const wsUrl = newEndpoint.replace('http://', 'ws://').replace('/api', '/api/mcp-ws/'); await wsClient.connect(wsUrl); log(`WebSocket client connected to: ${wsUrl}`); } catch (error) { log("Failed to connect WebSocket client:", error); wsClient = null; } } else { log("WebSocket not supported or disabled, using HTTP REST API"); if (wsClient) { wsClient.disconnect(); wsClient = null; } } } cachedEndpoint = newEndpoint; log(`Updated cachedEndpoint to: ${cachedEndpoint}`); log(`WebSocket client status: ${wsClient ? 'connected' : 'not available'}`); } catch (error) { // If we fail to find a working endpoint, keep the old one if it existed. log("Failed to update IDE endpoint:", error); } } /** * Main MCP server */ const server = new Server({ name: "jetbrains/proxy", version: "0.1.0", }, { capabilities: { tools: { listChanged: true, }, resources: {}, }, instructions: "You can interact with JetBrains IntelliJ IDE and its features through this MCP (Model Context Protocol) server. " + "The server provides access to various IDE tools and functionalities including multi-project support and enhanced process management. " + "Key features:\n" + "• Multi-project operations: get_all_open_projects, switch_project\n" + "• Cross-project search: multi_project_search\n" + "• File operations: multi_project_get_file\n" + "• Run configurations: multi_project_run_config\n" + "• Process management: run_configuration (quick startup), get_process_status (real-time monitoring)\n" + "• Concurrent execution: Start and monitor multiple services simultaneously\n" + "• Real-time monitoring through the MCP Monitor tool window\n" + "All requests should be formatted as JSON objects according to the Model Context Protocol specification." }); /** * Handles listing tools by using the *cached* endpoint (no new search each time). */ server.setRequestHandler(ListToolsRequestSchema, async () => { log("Handling ListToolsRequestSchema request."); if (!cachedEndpoint) { // If no cached endpoint, we can't proceed throw new Error("No working IDE endpoint available."); } try { log(`Using cached endpoint ${cachedEndpoint} to list tools.`); const toolsResponse = await fetch(`${cachedEndpoint}/mcp/list_tools`); if (!toolsResponse.ok) { log(`Failed to fetch tools with status ${toolsResponse.status}`); throw new Error("Unable to list tools"); } const tools = await toolsResponse.json(); // Add our custom cluster and IDE management tools const customTools = [ { name: "get_cluster_nodes", description: "Get information about discovered MCP server nodes in the cluster", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "list_available_ides", description: "List all discovered JetBrains IDEs available for connection", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "switch_to_ide", description: "Switch connection to a specific JetBrains IDE (e.g., 'webstorm', 'intellij', 'phpstorm')", inputSchema: { type: "object", properties: { ide_name: { type: "string", description: "Name of the IDE to connect to (case-insensitive, partial match)" } }, required: ["ide_name"], additionalProperties: false } }, { name: "get_current_ide", description: "Get information about the currently connected IDE", inputSchema: { type: "object", properties: {}, additionalProperties: false } } ]; const allTools = [...tools, ...customTools]; log(`Successfully fetched tools: ${JSON.stringify(allTools)}`); return { tools: allTools }; } catch (error) { log("Error handling ListToolsRequestSchema request:", error); throw error; } }); /** * Handle calls to a specific tool using WebSocket (preferred) or HTTP fallback. */ async function handleToolCall(name, args) { log(`Handling tool call: name=${name}, args=${JSON.stringify(args)}`); // Handle our custom cluster and IDE management tools if (name === 'get_cluster_nodes') { const nodes = clusterDiscovery.getDiscoveredNodes(); const nodeInfo = nodes.map(node => ({ id: node.id, host: node.host, port: node.port, httpEndpoint: node.httpEndpoint, websocketEndpoint: node.websocketEndpoint, lastSeen: node.lastSeen.toISOString(), status: node.status, projectPaths: node.projectPaths, health: node.health })); return { content: [{ type: "text", text: JSON.stringify({ discoveredNodes: nodeInfo, totalNodes: nodeInfo.length, healthyNodes: nodeInfo.filter(n => n.health === 'HEALTHY').length, clusterMode: clusterMode, multicastGroup: '224.0.0.251:5353' }, null, 2) }], isError: false }; } if (name === 'list_available_ides') { const result = listAvailableIDEs(); return { content: [{ type: "text", text: result }], isError: false }; } if (name === 'switch_to_ide') { const ideName = args.ide_name; if (!ideName) { return { content: [{ type: "text", text: "IDE name is required" }], isError: true }; } const result = await switchToIDE(ideName); return { content: [{ type: "text", text: result }], isError: false }; } if (name === 'get_current_ide') { const preferredNodeId = clusterWebSocketManager.getPreferredNodeId(); if (preferredNodeId) { const currentNode = availableIDEs.get(preferredNodeId); if (currentNode) { const ideType = getIDETypeFromPort(currentNode.port); return { content: [{ type: "text", text: `Currently connected to: ${ideType} at ${currentNode.host}:${currentNode.port}` }], isError: false }; } } // Fallback to first available IDE if no preferred node if (availableIDEs.size > 0) { const firstIDE = Array.from(availableIDEs.values())[0]; const ideType = getIDETypeFromPort(firstIDE.port); return { content: [{ type: "text", text: `Connected to: ${ideType} at ${firstIDE.host}:${firstIDE.port} (default connection)` }], isError: false }; } return { content: [{ type: "text", text: "No IDE currently connected" }], isError: false }; } if (!cachedEndpoint) { throw new Error("No working IDE endpoint available."); } // Enhanced logging for various operation types if (name.startsWith('multi_project_') || name.includes('project')) { log(`Multi-project operation detected: ${name}`); } if (name === 'run_configuration') { log(`Starting process configuration: ${args.configName}`); } if (name === 'get_process_status') { log(`Checking process status for: ${args.configName}`); } // Try cluster WebSocket manager first if in cluster mode if (clusterMode && clusterWebSocketManager.hasHealthyConnections()) { try { log(`Using cluster WebSocket for tool call: ${name}`); const projectPath = extractProjectPath(args); const result = await clusterWebSocketManager.executeToolRequest(name, args, projectPath); const isError = !!result.error; const text = result.status ?? result.error; log(`Cluster WebSocket response: ${text}`); return { content: [{ type: "text", text: text }], isError, }; } catch (error) { log(`Cluster WebSocket request failed, falling back: ${error.message}`); // Continue to single WebSocket or HTTP fallback } } // Try single WebSocket if available if (wsClient && wsClient.isConnected()) { try { log(`Using single WebSocket for tool call: ${name}`); const result = await wsClient.sendToolRequest(name, args); const isError = !!result.error; const text = result.status ?? result.error; log(`WebSocket response: ${text}`); return { content: [{ type: "text", text: text }], isError, }; } catch (error) { log(`WebSocket request failed, falling back to HTTP: ${error.message}`); // Continue to HTTP fallback } } // HTTP fallback try { log(`Using HTTP REST API for tool call: ${name}`); log(`ENDPOINT: ${cachedEndpoint} | Tool name: ${name} | args: ${JSON.stringify(args)}`); const response = await fetch(`${cachedEndpoint}/mcp/${name}`, { method: 'POST', headers: { "Content-Type": "application/json", }, body: JSON.stringify(args), }); if (!response.ok) { log(`Response failed with status ${response.status} for tool ${name}`); // Enhanced error messages for different operation types if (name.startsWith('multi_project_') && response.status === 404) { throw new Error(`Multi-project tool '${name}' not found. Please ensure you're using MCP Server Plugin v1.1.0+`); } if (name.startsWith('cluster_') && response.status === 404) { throw new Error(`Cluster discovery tool '${name}' not found. Please ensure you're using MCP Server Plugin v1.2.0+`); } if ((name === 'run_configuration' || name === 'get_process_status') && response.status === 404) { throw new Error(`Process management tool '${name}' not found. Please ensure you're using MCP Server Plugin v1.1.0+`); } throw new Error(`Response failed: ${response.status}`); } // Parse the IDE's JSON response const { status, error } = await response.json(); log("Parsed HTTP response:", { status, error }); const isError = !!error; const text = status ?? error; log("Final response text:", text); log("Is error:", isError); return { content: [{ type: "text", text: text }], isError, }; } catch (error) { log("Error in handleToolCall:", error); // Enhanced error handling for different operation types if (name.startsWith('multi_project_') || name.includes('project')) { log(`Error in multi-project operation ${name}:`, error); } if (name === 'run_configuration' || name === 'get_process_status') { log(`Error in process management operation ${name}:`, error); } return { content: [{ type: "text", text: error instanceof Error ? error.message : "Unknown error", }], isError: true, }; } } /** * Request handler for "CallToolRequestSchema" */ server.setRequestHandler(CallToolRequestSchema, async (request) => { log("Handling CallToolRequestSchema request:", request); try { const result = await handleToolCall(request.params.name, request.params.arguments ?? {}); log("Tool call handled successfully:", result); return result; } catch (error) { log("Error handling CallToolRequestSchema request:", error); throw error; } }); /** * Starts the server, connects via stdio, and schedules endpoint checks. */ async function runServer() { log("Initializing server..."); // Always initialize cluster discovery for automatic IDE scanning try { log("Starting cluster discovery and automatic IDE scanning..."); await clusterWebSocketManager.start(); // Set up IDE discovery callbacks clusterDiscovery.onNodeDiscovered((node) => { availableIDEs.set(node.id, node); log(`Discovered IDE: ${getIDETypeFromPort(node.port)} at ${node.host}:${node.port}`); }); clusterDiscovery.onNodeRemoved((nodeId) => { const node = availableIDEs.get(nodeId); if (node) { log(`IDE disconnected: ${getIDETypeFromPort(node.port)} at ${node.host}:${node.port}`); availableIDEs.delete(nodeId); } }); clusterMode = true; log("Cluster discovery enabled successfully"); // Give some time for initial discovery await new Promise(resolve => setTimeout(resolve, 2000)); if (availableIDEs.size > 0) { log(`Found ${availableIDEs.size} IDE(s):`); availableIDEs.forEach((node, id) => { log(` - ${getIDETypeFromPort(node.port)} at ${node.host}:${node.port}`); }); } else { log("No IDEs found during initial scan"); } } catch (error) { log("Failed to start cluster discovery, falling back to single-node mode:", error); clusterMode = false; } // 1) Do an initial endpoint check (once at startup) await updateIDEEndpoint(); const transport = new StdioServerTransport(); try { await server.connect(transport); log("Server connected to transport."); } catch (error) { log("Error connecting server to transport:", error); throw error; } // 2) Then check again every 10 seconds (in case IDE restarts or ports change) setInterval(updateIDEEndpoint, 10_000); log("Scheduled endpoint check every 10 seconds."); // 3) Log cluster stats periodically if in cluster mode if (clusterMode) { setInterval(() => { const stats = clusterWebSocketManager.getConnectionStats(); log(`Cluster stats: ${stats.connectedNodes}/${stats.totalNodes} nodes connected, ${stats.totalActiveRequests} active requests`); }, 30_000); // Every 30 seconds } log("JetBrains Proxy MCP Server running on stdio"); if (clusterMode) { log("Cluster discovery mode: ENABLED"); } else { log("Cluster discovery mode: DISABLED (use CLUSTER_DISCOVERY=true to enable)"); } } // Handle command line arguments FIRST const args = process.argv.slice(2); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJsonPath = join(__dirname, '..', 'package.json'); if (args.includes('--version') || args.includes('-v')) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); console.log(packageJson.version); } catch (error) { console.log('1.14.2'); // fallback version } process.exit(0); } if (args.includes('--help') || args.includes('-h')) { let version = '1.14.2'; try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); version = packageJson.version; } catch (error) { // use fallback version } console.log(` JetBrains MCP Proxy Server v${version} Usage: npx @jetbrains/mcp-proxy [options] Options: --version, -v Show version number --help, -h Show this help message Environment Variables: IDE_PORT Specific IDE port to connect to HOST IDE host address (default: 127.0.0.1) LOG_ENABLED Enable logging (true/false) Example: IDE_PORT=63342 npx @dddvchang/mcp-proxy HOST=192.168.1.100 IDE_PORT=63342 npx @dddvchang/mcp-proxy `); process.exit(0); } // Start the server runServer().catch(error => { log("Server failed to start:", error); });