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