UNPKG

mcp-http-bridge

Version:

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

1,003 lines (845 loc) โ€ข 33.6 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 { 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'; import { randomUUID } from 'node:crypto'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; const execAsync = promisify(exec); // Setup file logging (only if debug enabled) const logFile = DEBUG_ENABLED ? process.env.MCP_BRIDGE_LOG_FILE || './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', 'MCP_AUTH_URL', 'MCP_TOKEN_URL', '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 * Supports OAuth authentication with direct token storage */ class CursorHTTPBridge { constructor() { this.stdioServer = null; this.stdioTransport = null; this.mcpSessionId = null; // MCP protocol session ID (assigned by server) this.authSessionId = this.generateAuthSessionId(); // Our auth session ID this.authCompleted = false; this.accessToken = null; // Store the actual OAuth token this.refreshToken = null; // Store refresh token if available this.tokenExpiry = null; // Store token expiry time this.authCancelled = false; // Track if auth was cancelled this.shuttingDown = false; // Track if bridge is shutting down } /** * Generate a unique auth session ID for OAuth flow */ generateAuthSessionId() { const timestamp = Date.now(); const uuid = randomUUID().split('-')[0]; // Use first part of UUID return `mcp-bridge-${timestamp}-${uuid}`; } /** * 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 headers including access token for authenticated requests * Combines environment variables with the OAuth access token */ getHeadersWithAuth() { const headers = this.envToHeaders(); // Add access token as MCP_AUTH_TOKEN environment variable header if (this.accessToken) { headers['X-MCP-MCP-AUTH-TOKEN'] = this.accessToken; log(`๐ŸŽซ Added access token as X-MCP-MCP-AUTH-TOKEN header`); } return headers; } /** * Build complete HTTP request headers for MCP requests * @param {boolean} requireAuth - Whether to include authentication headers * @param {boolean} isInitialization - Whether this is an initialization request * @returns {Object} Complete headers object ready for HTTP request */ buildRequestHeaders(requireAuth = true, isInitialization = false) { // Get environment variable headers based on auth requirement let envHeaders; if (requireAuth && this.accessToken) { envHeaders = this.getHeadersWithAuth(); log(`๐Ÿ” Using authenticated headers with access token`); } else { envHeaders = this.envToHeaders(); log(`๐Ÿ“ Using basic headers for ${requireAuth ? 'unauthenticated' : 'discovery'} request`); } // Build base headers const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', 'Accept-Language': '*', 'Sec-Fetch-Mode': 'cors', 'User-Agent': 'node', ...envHeaders }; // Add OAuth Authorization header if we have a token and auth is required if (requireAuth && this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; log(`๐Ÿ” Added OAuth Bearer token to Authorization header`); } // Add MCP session ID if we have one (for protocol) if (!isInitialization && this.mcpSessionId) { headers['mcp-session-id'] = this.mcpSessionId; log(`๐Ÿ“‹ Added MCP session ID: ${this.mcpSessionId}`); } else if (isInitialization) { log(`๐Ÿ†• Initialization request - no MCP session ID yet`); } return headers; } /** * Get list of environment variables to pass as headers * Only passes variables explicitly listed in MCP_PASS_VARS */ 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); } // No MCP_PASS_VARS defined - pass nothing log('๐Ÿ“ No MCP_PASS_VARS defined - passing no environment variables'); log('๐Ÿ’ก Set MCP_PASS_VARS to explicitly define which variables to pass as headers'); return []; } /** * Ensure authentication is completed (lazy authentication) * Only authenticates when actually needed (e.g., for tool calls) */ async ensureAuthenticated() { const authUrl = process.env.MCP_AUTH_URL; const authType = process.env.MCP_AUTH_TYPE; if (authUrl && authType === 'oauth' && !this.authCompleted) { log(`๐Ÿ” Authentication required for tool call. Auth Session ID: ${this.authSessionId}`); // Check if we already have a valid token if (this.isTokenValid()) { log(`โœ… Existing valid token found`); this.authCompleted = true; return; } // Initiate OAuth flow await this.initiateOAuthFlow(authUrl); // Wait for authentication completion and token retrieval await this.waitForAuthCompletion(); this.authCompleted = true; log(`โœ… Authentication completed for session ${this.authSessionId}`); } else if (!authUrl) { log(`๐Ÿ“ No MCP_AUTH_URL configured - skipping authentication`); this.authCompleted = true; } else if (authType === 'pat') { log(`๐Ÿ”‘ Using PAT authentication - skipping OAuth flow`); this.authCompleted = true; } else if (authType && authType !== 'oauth') { log(`๐Ÿ”‘ Using ${authType} authentication - skipping OAuth flow`); this.authCompleted = true; } else { log(`โœ… Authentication already completed for session ${this.authSessionId}`); } } /** * Check if authentication is required and initiate OAuth flow * @deprecated Use ensureAuthenticated() instead for lazy authentication */ async checkAndInitiateAuth() { const authUrl = process.env.MCP_AUTH_URL; const authType = process.env.MCP_AUTH_TYPE; if (authUrl && authType === 'oauth' && !this.authCompleted) { log(`๐Ÿ” Authentication required. Auth Session ID: ${this.authSessionId}`); // Check if we already have a valid token if (this.isTokenValid()) { log(`โœ… Existing valid token found`); this.authCompleted = true; return; } // Initiate OAuth flow await this.initiateOAuthFlow(authUrl); // Wait for authentication completion and token retrieval await this.waitForAuthCompletion(); this.authCompleted = true; log(`โœ… Authentication completed for session ${this.authSessionId}`); } else if (!authUrl) { log(`๐Ÿ“ No MCP_AUTH_URL configured - skipping authentication`); this.authCompleted = true; } else if (authType === 'pat') { log(`๐Ÿ”‘ Using PAT authentication - skipping OAuth flow`); this.authCompleted = true; } else if (authType && authType !== 'oauth') { log(`๐Ÿ”‘ Using ${authType} authentication - skipping OAuth flow`); this.authCompleted = true; } else { log(`โœ… Authentication already completed for session ${this.authSessionId}`); } } /** * Check if current token is valid and not expired */ isTokenValid() { if (!this.accessToken) { return false; } if (this.tokenExpiry && Date.now() >= this.tokenExpiry) { log(`โฐ Token expired at ${new Date(this.tokenExpiry).toISOString()}`); return false; } return true; } /** * Extract service name from MCP_AUTH_URL */ getServiceFromAuthUrl() { const authUrl = process.env.MCP_AUTH_URL; if (!authUrl) { return 'devops'; // fallback } // Extract service from URL like: /api/auth/mcp-session/devops const match = authUrl.match(/\/mcp-session\/([^/?]+)/); return match ? match[1] : 'devops'; } /** * Initiate OAuth flow by opening browser */ async initiateOAuthFlow(authUrl) { const fullAuthUrl = `${authUrl}?session_id=${this.authSessionId}`; log(`๐ŸŒ Opening browser for authentication...`); log(`๐Ÿ“‹ Auth Session ID: ${this.authSessionId}`); log(`๐Ÿ”— Auth URL: ${fullAuthUrl}`); try { // Try to open browser automatically await this.openBrowser(fullAuthUrl); log(`โœ… Browser opened successfully`); } catch (error) { log(`โš ๏ธ Could not open browser automatically: ${error.message}`); console.error(`\n๐Ÿ” AUTHENTICATION REQUIRED`); console.error(`๐Ÿ“‹ Auth Session ID: ${this.authSessionId}`); console.error(`๐Ÿ”— Please visit: ${fullAuthUrl}`); console.error(`โณ Waiting for authentication completion...\n`); } } /** * Open browser with the auth URL */ async openBrowser(url) { const platform = process.platform; let command; switch (platform) { case 'darwin': // macOS command = `open "${url}"`; break; case 'win32': // Windows command = `start "" "${url}"`; break; default: // Linux and others command = `xdg-open "${url}"`; break; } await execAsync(command); } /** * Wait for authentication completion and retrieve token */ async waitForAuthCompletion() { const tokenEndpoint = process.env.MCP_TOKEN_URL; if (!tokenEndpoint) { throw new Error('MCP_TOKEN_URL required for authentication'); } const maxAttempts = 60; // Wait up to 5 minutes (60 * 5 seconds) const timeoutMs = 5 * 60 * 1000; // 5 minutes total timeout const startTime = Date.now(); let attempts = 0; log(`โณ Waiting for authentication completion...`); log(`โฐ Timeout in ${maxAttempts * 5} seconds. Press Ctrl+C to cancel.`); // Set up cancellation handlers const cancelHandler = () => { log(`๐Ÿ›‘ Authentication cancelled by user`); this.authCancelled = true; }; process.on('SIGINT', cancelHandler); process.on('SIGTERM', cancelHandler); try { while (attempts < maxAttempts && !this.authCancelled && !this.shuttingDown) { // Check total elapsed time const elapsed = Date.now() - startTime; if (elapsed >= timeoutMs) { log(`โฐ Authentication timeout after ${Math.round(elapsed / 1000)}s`); break; } try { // Poll for token using the auth session ID const tokenResponse = await this.retrieveToken(tokenEndpoint); if (tokenResponse) { log(`โœ… Authentication completed successfully!`); return; } // Wait 5 seconds before next attempt, but check for cancellation during wait await this.interruptibleWait(5000); attempts++; if (attempts % 6 === 0) { // Log every 30 seconds const remainingTime = Math.max(0, Math.round((timeoutMs - elapsed) / 1000)); log(`โณ Still waiting for authentication... (${Math.round(elapsed / 1000)}s elapsed, ${remainingTime}s remaining)`); log(`๐Ÿ’ก Complete OAuth in browser or press Ctrl+C to cancel`); } } catch (error) { // Handle specific error types if (error.message.includes('fetch') || error.message.includes('ECONNREFUSED')) { log(`๐Ÿ”Œ Connection error: ${error.message}`); log(`โš ๏ธ Token endpoint may be unreachable. Check MCP_TOKEN_URL configuration.`); } else { log(`๐Ÿ” Token retrieval error: ${error.message}`); } await this.interruptibleWait(5000); attempts++; } } // Handle different exit conditions if (this.authCancelled) { throw new Error('Authentication cancelled by user'); } else if (this.shuttingDown) { throw new Error('Bridge is shutting down'); } else { throw new Error(`Authentication timeout after ${Math.round((Date.now() - startTime) / 1000)} seconds. Please try again.`); } } finally { // Clean up event listeners process.removeListener('SIGINT', cancelHandler); process.removeListener('SIGTERM', cancelHandler); } } /** * Wait for specified time but allow interruption */ async interruptibleWait(ms) { return new Promise((resolve) => { const checkInterval = 100; // Check every 100ms let elapsed = 0; const interval = setInterval(() => { elapsed += checkInterval; if (this.authCancelled || this.shuttingDown || elapsed >= ms) { clearInterval(interval); resolve(); } }, checkInterval); }); } /** * Retrieve token from the token endpoint */ async retrieveToken(tokenEndpoint) { try { const response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', session_id: this.authSessionId }) }); if (!response.ok) { if (response.status === 404 || response.status === 401) { // Token not ready yet, continue polling return null; } throw new Error(`Token endpoint returned ${response.status}: ${response.statusText}`); } const tokenData = await response.json(); if (tokenData.access_token) { this.accessToken = tokenData.access_token; this.refreshToken = tokenData.refresh_token; // Calculate token expiry if (tokenData.expires_in) { this.tokenExpiry = Date.now() + (tokenData.expires_in * 1000); log(`๐ŸŽซ Token expires at: ${new Date(this.tokenExpiry).toISOString()}`); } log(`๐ŸŽซ Access token retrieved successfully`); log(`๐Ÿ”„ Refresh token available: ${!!this.refreshToken}`); return tokenData; } return null; } catch (error) { log(`โŒ Token retrieval failed: ${error.message}`); throw error; } } /** * Refresh the access token using refresh token */ async refreshAccessToken() { if (!this.refreshToken) { throw new Error('No refresh token available'); } const tokenEndpoint = process.env.MCP_TOKEN_URL; if (!tokenEndpoint) { throw new Error('MCP_TOKEN_URL required for token refresh'); } try { log(`๐Ÿ”„ Refreshing access token...`); const response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: this.refreshToken }) }); if (!response.ok) { throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`); } const tokenData = await response.json(); if (tokenData.access_token) { this.accessToken = tokenData.access_token; // Update refresh token if provided if (tokenData.refresh_token) { this.refreshToken = tokenData.refresh_token; } // Calculate new token expiry if (tokenData.expires_in) { this.tokenExpiry = Date.now() + (tokenData.expires_in * 1000); log(`๐ŸŽซ Refreshed token expires at: ${new Date(this.tokenExpiry).toISOString()}`); } log(`โœ… Access token refreshed successfully`); return true; } throw new Error('No access token in refresh response'); } catch (error) { log(`โŒ Token refresh failed: ${error.message}`); // Clear tokens on refresh failure this.accessToken = null; this.refreshToken = null; this.tokenExpiry = null; throw error; } } /** * 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}`); log(`๐Ÿ“‹ Auth Session ID: ${this.authSessionId}`); this.serverUrl = serverUrl; log(`โœ… Custom HTTP bridge configured`); // 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) }, false, false); // Don't require auth for tool discovery return response.result || response; }); // Call tool this.stdioServer.setRequestHandler(CallToolRequestSchema, async (request) => { // Ensure authentication is completed before making tool calls await this.ensureAuthenticated(); 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) }, false, false); // Don't require auth for resource discovery return response.result || response; }); // Read resource this.stdioServer.setRequestHandler(ReadResourceRequestSchema, async (request) => { // Ensure authentication is completed before reading resources await this.ensureAuthenticated(); 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) }, false, false); // Don't require auth for prompt discovery // Ensure we return the expected format: { prompts: [...] } const result = response.result || response; if (result && typeof result === 'object' && Array.isArray(result.prompts)) { return result; } else { // Fallback to empty prompts if format is unexpected log(`โš ๏ธ Unexpected prompts response format: ${JSON.stringify(result)}`); return { prompts: [] }; } }); // Get prompt this.stdioServer.setRequestHandler(GetPromptRequestSchema, async (request) => { // Ensure authentication is completed before getting prompts await this.ensureAuthenticated(); 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 and OAuth authentication */ async makeHttpRequest(jsonrpcRequest, isInitialization = false, requireAuth = true) { try { log(`๐ŸŒ Making HTTP request: ${jsonrpcRequest.method}`); // Check if token needs refresh before making request (only if auth is required) if (!isInitialization && requireAuth && this.accessToken && this.tokenExpiry && Date.now() >= this.tokenExpiry - 60000) { // Refresh token if it expires within 1 minute try { await this.refreshAccessToken(); } catch (error) { log(`โš ๏ธ Token refresh failed, continuing with existing token: ${error.message}`); } } // Build request headers using helper function const requestHeaders = this.buildRequestHeaders(requireAuth, isInitialization); const response = await fetch(this.serverUrl, { method: 'POST', headers: requestHeaders, body: JSON.stringify(jsonrpcRequest) }); // Extract MCP session ID from response headers if this is initialization if (isInitialization) { log(`๐Ÿ” Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`); const mcpSessionId = response.headers.get('mcp-session-id'); if (mcpSessionId) { this.mcpSessionId = mcpSessionId; log(`๐Ÿ“‹ Server assigned MCP session ID: ${this.mcpSessionId}`); } else { log(`โš ๏ธ No MCP session ID found in response headers - server may not use sessions`); } } // Handle authentication errors (only if auth is required) if (response.status === 401 && requireAuth && this.accessToken) { log(`๐Ÿ” Received 401 Unauthorized, attempting token refresh...`); try { await this.refreshAccessToken(); // Retry the request with new token log(`๐Ÿ”„ Retrying request with refreshed token...`); // Rebuild headers with new access token using helper function const retryHeaders = this.buildRequestHeaders(requireAuth, isInitialization); const retryResponse = await fetch(this.serverUrl, { method: 'POST', headers: retryHeaders, body: JSON.stringify(jsonrpcRequest) }); if (retryResponse.ok) { log(`โœ… Request succeeded after token refresh`); return await this.parseResponse(retryResponse, jsonrpcRequest.method); } else { throw new Error(`Request failed after token refresh: ${retryResponse.status} ${retryResponse.statusText}`); } } catch (refreshError) { log(`โŒ Token refresh failed: ${refreshError.message}`); throw new Error(`Authentication failed and token refresh unsuccessful: ${refreshError.message}`); } } if (!response.ok) { const errorText = await response.text(); log(`โŒ HTTP ${response.status}: ${errorText.substring(0, 200)}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await this.parseResponse(response, jsonrpcRequest.method); } catch (error) { log(`โŒ HTTP request failed: ${error.message}`); throw error; } } /** * Parse HTTP response (JSON or SSE) */ async parseResponse(response, method) { 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: ${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): ${method}`); return { jsonrpc: '2.0', result: null }; } try { const result = JSON.parse(responseText); log(`โœ… HTTP request successful: ${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 (method && method.startsWith('notifications/')) { log(`โœ… Notification sent successfully (non-JSON response): ${method}`); return { jsonrpc: '2.0', result: null }; } throw new Error(`Invalid JSON response: ${parseError.message}`); } } } /** * Cleanup on exit */ async cleanup() { if (this.shuttingDown) { return; // Already cleaning up } this.shuttingDown = true; log('๐Ÿงน Cleaning up bridge...'); // Cancel any ongoing authentication if (!this.authCompleted) { this.authCancelled = true; log('๐Ÿ›‘ Cancelling ongoing authentication...'); } // Close stdio server if (this.stdioServer) { try { await this.stdioServer.close(); log('โœ… Stdio server closed'); } catch (error) { log(`โš ๏ธ Error closing stdio server: ${error.message}`); } } // Clear tokens this.accessToken = null; this.refreshToken = null; this.tokenExpiry = null; log('โœ… Bridge cleanup completed'); } } // Handle graceful shutdown const bridge = new CursorHTTPBridge(); let isShuttingDown = false; const gracefulShutdown = async (signal) => { if (isShuttingDown) { log(`๐Ÿ”ฅ Force exit on second ${signal}`); process.exit(1); } isShuttingDown = true; log(`๐Ÿ“ก Received ${signal}, shutting down gracefully...`); try { await bridge.cleanup(); log(`โœ… Graceful shutdown completed`); process.exit(0); } catch (error) { log(`โŒ Error during shutdown: ${error.message}`); process.exit(1); } }; process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); // Handle uncaught exceptions process.on('uncaughtException', (error) => { log(`๐Ÿ’ฅ Uncaught exception: ${error.message}`); gracefulShutdown('uncaughtException'); }); process.on('unhandledRejection', (reason, promise) => { log(`๐Ÿ’ฅ Unhandled rejection at: ${promise}, reason: ${reason}`); gracefulShutdown('unhandledRejection'); }); // Start the bridge bridge.start().catch(async (error) => { log(`โŒ Failed to start bridge: ${error.message}`); await bridge.cleanup(); process.exit(1); });