UNPKG

mcp-http-bridge

Version:

Generic bridge client for connecting MCP clients to remote MCP servers via HTTP supporting session based environment variables

501 lines (423 loc) โ€ข 16.4 kB
#!/usr/bin/env node // Check if debug logging is enabled const DEBUG_ENABLED = process.env.MCP_BRIDGE_DEBUG === '1' || process.env.MCP_BRIDGE_DEBUG === 'true'; // IMMEDIATE DEBUG - Log to stderr before any imports (only if debug enabled) if (DEBUG_ENABLED) { console.error('๐Ÿ”ฅ BRIDGE DEBUG: Bridge starting up...'); console.error('๐Ÿ”ฅ BRIDGE DEBUG: Working directory:', process.cwd()); console.error('๐Ÿ”ฅ BRIDGE DEBUG: MCP_SERVER_URL:', process.env.MCP_SERVER_URL || 'NOT SET'); console.error('๐Ÿ”ฅ BRIDGE DEBUG: MCP_PASS_VARS:', process.env.MCP_PASS_VARS || 'NOT SET'); } import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import fs from 'fs'; // Setup file logging (only if debug enabled) const logFile = DEBUG_ENABLED ? 'C:\\mcp-http-bridge\\bridge-debug.log' : null; const logStream = DEBUG_ENABLED ? fs.createWriteStream(logFile, { flags: 'a' }) : null; function log(message) { if (!DEBUG_ENABLED) return; // Skip logging if debug is disabled const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${message}\n`; // Log to stderr (for console) console.error(message); // Log to file if (logStream) { try { logStream.write(logMessage); } catch (error) { console.error('๐Ÿ”ฅ BRIDGE DEBUG: Failed to write to log file:', error.message); } } } // Log all environment variables that might be relevant const relevantEnvVars = [ 'MCP_SERVER_URL', 'MCP_PASS_VARS', 'SQL_SERVER', 'SQL_DATABASE', 'SQL_USER', 'SQL_PASSWORD' ]; log('๐Ÿ” Environment variables check:'); for (const envVar of relevantEnvVars) { const value = process.env[envVar]; if (value) { const logValue = envVar.includes('PASSWORD') ? '***' : value; log(` ${envVar}: ${logValue}`); } else { log(` ${envVar}: NOT SET`); } } // Log total number of environment variables log(`๐Ÿ“Š Total environment variables: ${Object.keys(process.env).length}`); // Log first few environment variables to see what's available const envKeys = Object.keys(process.env).slice(0, 10); log(`๐Ÿ”ค First 10 env var names: ${envKeys.join(', ')}`); log('๐ŸŽฌ Starting main bridge logic...'); /** * Generic Cursor-to-HTTP bridge for MCP servers * Converts stdio interface (for Cursor) to HTTP requests (for remote MCP servers) * Automatically passes environment variables as X-MCP-* headers */ class CursorHTTPBridge { constructor() { this.httpClient = null; this.httpTransport = null; this.stdioServer = null; this.stdioTransport = null; this.sessionId = null; // Let server assign session ID } /** * Convert environment variables to HTTP headers * Uses MCP_PASS_VARS for explicit control, falls back to auto-detection */ envToHeaders() { const headers = {}; // Get variables to pass (explicit list or auto-detected) const varsToPass = this.getVariablesToPass(); log(`๐Ÿ”ง Variables to pass: ${varsToPass.join(', ')}`); // Convert selected variables to headers for (const key of varsToPass) { const value = process.env[key]; if (value !== undefined) { const headerName = `X-MCP-${key.replace(/_/g, '-')}`; headers[headerName] = value; // Log without sensitive values const logValue = key.includes('PASSWORD') || key.includes('SECRET') || key.includes('KEY') ? '***' : value; log(` ${key} -> ${headerName}: ${logValue}`); } else { log(` ${key}: NOT FOUND in environment`); } } log(`๐Ÿš€ Final headers to send: ${Object.keys(headers).join(', ')}`); return headers; } /** * Get list of environment variables to pass as headers * Priority: MCP_PASS_VARS (explicit) > auto-detection (fallback) */ getVariablesToPass() { const passVars = process.env.MCP_PASS_VARS; if (passVars) { // Explicit list provided - use only these variables log(`๐ŸŽฏ Using explicit variable list: ${passVars}`); return passVars.split(',').map(v => v.trim()).filter(v => v.length > 0); } // Fallback: auto-detect non-system variables log('๐Ÿ” Auto-detecting variables (consider using MCP_PASS_VARS for explicit control)'); return this.autoDetectConfiguredVariables(); } /** * Auto-detect which variables were likely configured for the MCP server * Excludes system variables and bridge-specific variables */ autoDetectConfiguredVariables() { const configuredVars = []; // System variables that should never be passed const systemVars = [ 'PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'PWD', 'OLDPWD', 'NODE_ENV', 'NODE_PATH', 'NODE_OPTIONS', 'INIT_CWD', 'LANG', 'LC_', 'TZ', 'TMPDIR', 'TEMP', 'TMP', 'PROCESSOR_', 'NUMBER_OF_PROCESSORS', 'OS', 'COMPUTERNAME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'SYSTEMROOT', 'WINDIR', 'COMSPEC' ]; // Bridge-specific variables const bridgeVars = ['MCP_SERVER_URL', 'MCP_PASS_VARS']; // Check all environment variables for (const [key, value] of Object.entries(process.env)) { // Skip system variables if (systemVars.some(sysVar => key.startsWith(sysVar))) { continue; } // Skip npm variables if (key.startsWith('npm_')) { continue; } // Skip bridge-specific variables if (bridgeVars.includes(key)) { continue; } // Everything else gets passed (potentially risky with many variables) configuredVars.push(key); } return configuredVars; } /** * Start the bridge */ async start() { try { // Get configuration const serverUrl = process.env.MCP_SERVER_URL; if (!serverUrl) { throw new Error('MCP_SERVER_URL environment variable is required'); } log(`๐ŸŒ‰ Starting Cursor-HTTP bridge to: ${serverUrl}`); // Convert environment variables to headers const headers = this.envToHeaders(); log(`๐Ÿ”ง Passing ${Object.keys(headers).length} environment variables as headers`); // Store headers for use in requests this.customHeaders = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', 'Accept-Language': '*', 'Sec-Fetch-Mode': 'cors', 'User-Agent': 'node', ...headers }; this.serverUrl = serverUrl; log(`โœ… Custom HTTP bridge configured`); log(`๐Ÿ”ง Will send headers with every request`); // Create stdio server for Cursor (skip MCP SDK HTTP client) await this.createStdioServer(); } catch (error) { log('โŒ Failed to start bridge: ' + error.message); process.exit(1); } } /** * Create stdio server that Cursor can connect to */ async createStdioServer() { // Create a server that bridges requests to the HTTP client this.stdioServer = new Server( { name: 'cursor-http-bridge', version: '1.0.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } } ); // Initialize connection to remote server first await this.initializeRemoteConnection(); // Bridge all MCP requests to the HTTP client this.setupRequestHandlers(); // Create stdio transport this.stdioTransport = new StdioServerTransport(); await this.stdioServer.connect(this.stdioTransport); log('๐Ÿ”Œ Bridge ready - Cursor can now connect via stdio'); } /** * Initialize connection to remote MCP server */ async initializeRemoteConnection() { try { log('๐Ÿค Initializing connection to remote MCP server...'); const initResponse = await this.makeHttpRequest({ jsonrpc: '2.0', method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'cursor-http-bridge', version: '1.0.0' } }, id: 1 }, true); // Pass true to indicate this is initialization log('โœ… Remote MCP server initialized successfully'); log(`๐Ÿ“‹ Server info: ${initResponse.result?.serverInfo?.name || 'Unknown'}`); // Store server capabilities this.serverCapabilities = initResponse.result?.capabilities || {}; // Send initialized notification to complete handshake await this.makeHttpRequest({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }, false); // Not initialization, use session ID log('โœ… Initialized notification sent to server'); } catch (error) { log(`โŒ Failed to initialize remote MCP server: ${error.message}`); throw error; } } /** * Setup request handlers that forward to HTTP client */ setupRequestHandlers() { // List tools this.stdioServer.setRequestHandler(ListToolsRequestSchema, async () => { const response = await this.makeHttpRequest({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: Math.floor(Math.random() * 1000000) }); return response.result || response; }); // Call tool this.stdioServer.setRequestHandler(CallToolRequestSchema, async (request) => { const response = await this.makeHttpRequest({ jsonrpc: '2.0', method: 'tools/call', params: request.params, id: Math.floor(Math.random() * 1000000) }); return response.result || response; }); // List resources this.stdioServer.setRequestHandler(ListResourcesRequestSchema, async () => { const response = await this.makeHttpRequest({ jsonrpc: '2.0', method: 'resources/list', params: {}, id: Math.floor(Math.random() * 1000000) }); return response.result || response; }); // Read resource this.stdioServer.setRequestHandler(ReadResourceRequestSchema, async (request) => { const response = await this.makeHttpRequest({ jsonrpc: '2.0', method: 'resources/read', params: request.params, id: Math.floor(Math.random() * 1000000) }); return response.result || response; }); // List prompts this.stdioServer.setRequestHandler(ListPromptsRequestSchema, async () => { const response = await this.makeHttpRequest({ jsonrpc: '2.0', method: 'prompts/list', params: {}, id: Math.floor(Math.random() * 1000000) }); return response.result || response; }); // Get prompt this.stdioServer.setRequestHandler(GetPromptRequestSchema, async (request) => { const response = await this.makeHttpRequest({ jsonrpc: '2.0', method: 'prompts/get', params: request.params, id: Math.floor(Math.random() * 1000000) }); return response.result || response; }); } /** * Make HTTP request to MCP server with custom headers */ async makeHttpRequest(jsonrpcRequest, isInitialization = false) { try { log(`๐ŸŒ Making HTTP request: ${jsonrpcRequest.method}`); // Prepare headers const requestHeaders = { ...this.customHeaders }; // Only add session ID for non-initialization requests if (!isInitialization && this.sessionId) { requestHeaders['mcp-session-id'] = this.sessionId; log(`๐Ÿ“‹ Using session ID: ${this.sessionId}`); } else if (isInitialization) { log(`๐Ÿ†• Initialization request - no session ID, server will assign one`); } else { log(`๐Ÿ“‹ No session ID available yet`); } const response = await fetch(this.serverUrl, { method: 'POST', headers: requestHeaders, body: JSON.stringify(jsonrpcRequest) }); // Extract session ID from response headers if this is initialization if (isInitialization) { log(`๐Ÿ” Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); const sessionId = response.headers.get('mcp-session-id'); if (sessionId) { this.sessionId = sessionId; log(`๐Ÿ“‹ Server assigned session ID: ${this.sessionId}`); } else { log(`โš ๏ธ No session ID found in response headers - server may not use sessions`); } } if (!response.ok) { const errorText = await response.text(); log(`โŒ HTTP ${response.status}: ${errorText.substring(0, 200)}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const contentType = response.headers.get('content-type') || ''; if (contentType.includes('text/event-stream')) { // Handle Server-Sent Events response const text = await response.text(); log(`๐Ÿ“ก Received SSE response: ${text.substring(0, 100)}...`); // Parse SSE format: "event: message\ndata: {...}\n\n" const lines = text.split('\n'); let jsonData = null; for (const line of lines) { if (line.startsWith('data: ')) { const dataStr = line.substring(6); // Remove "data: " prefix try { jsonData = JSON.parse(dataStr); break; } catch (e) { // Continue looking for valid JSON } } } if (jsonData) { log(`โœ… HTTP request successful: ${jsonrpcRequest.method}`); return jsonData; } else { throw new Error('Could not parse SSE response'); } } else { // Handle regular JSON response const responseText = await response.text(); // Handle empty responses (common for notifications) if (!responseText.trim()) { log(`โœ… HTTP request successful (empty response): ${jsonrpcRequest.method}`); return { jsonrpc: '2.0', result: null }; } try { const result = JSON.parse(responseText); log(`โœ… HTTP request successful: ${jsonrpcRequest.method}`); return result; } catch (parseError) { log(`โš ๏ธ Failed to parse JSON response: ${responseText.substring(0, 100)}`); // For notifications, empty or non-JSON responses are often acceptable if (jsonrpcRequest.method && jsonrpcRequest.method.startsWith('notifications/')) { log(`โœ… Notification sent successfully (non-JSON response): ${jsonrpcRequest.method}`); return { jsonrpc: '2.0', result: null }; } throw new Error(`Invalid JSON response: ${parseError.message}`); } } } catch (error) { log(`โŒ HTTP request failed: ${error.message}`); throw error; } } /** * Cleanup on exit */ async cleanup() { log('๐Ÿงน Cleaning up bridge...'); if (this.stdioServer) { await this.stdioServer.close(); } } } // Handle graceful shutdown const bridge = new CursorHTTPBridge(); process.on('SIGINT', async () => { await bridge.cleanup(); process.exit(0); }); process.on('SIGTERM', async () => { await bridge.cleanup(); process.exit(0); }); // Start the bridge bridge.start().catch(console.error);