UNPKG

@civic/nexus-bridge

Version:

Stdio <-> HTTP/SSE MCP bridge with Civic auth handling

617 lines 30.3 kB
/** * bridge.ts * * Connects stdio-based MCP clients (like Claude Desktop) with HTTP/SSE-based MCP servers. * Handles all aspects of message forwarding, authentication, and reconnection. * * Core Responsibilities: * 1. Authentication Flow: Manages Civic Auth login on startup by checking/refreshing tokens * and opening a browser for login when needed. Authentication is handled in * authProvider.ts which implements the OAuth PKCE flow. * * 2. Message Routing: Acts as a bidirectional proxy between local clients using * stdio transport and remote servers using HTTP/SSE. All messages are converted * from the local format to the remote format with authentication headers added. * * 3. Service Authorization: Detects when a remote server requires additional * authorization (e.g., for a third-party API), opens the browser to complete * the auth flow, and polls for completion. */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { McpError } from '@modelcontextprotocol/sdk/types.js'; import { authProvider } from './authProvider.js'; import * as config from './config.js'; import { isToolCallRequest, messageFromError, getHandlerForMethod } from './utils.js'; import { localTools, toolHandlers } from './tools/index.js'; import { ServiceAuthorizationHandler } from "./serviceAuthorization.js"; import { ClientConnectionMonitor } from './utils/clientDetection.js'; import { logger } from './utils/logger.js'; // Define ErrorCode for use in error creation var ErrorCode; (function (ErrorCode) { ErrorCode[ErrorCode["InternalError"] = -32603] = "InternalError"; ErrorCode[ErrorCode["ServerNotInitialized"] = -32002] = "ServerNotInitialized"; })(ErrorCode || (ErrorCode = {})); /** * The Bridge class implements the complete bridge functionality. * It handles: * 1. Local stdio server for LLM client connection * 2. Remote HTTP/SSE client for server connection * 3. Message forwarding in both directions * 4. Authentication flows and token management * 5. Service authorization detection and handling * 6. Request timeout management * 7. Capability registration and synchronization */ export class Bridge { // Core components localServer; remoteClient; stdioTransport; sseTransport = null; authProvider; serviceAuthorizationHandler; // State tracking isLocalServerConnected = false; isRemoteClientConnected = false; isShuttingDown = false; clientMonitor = null; remoteCapabilities = null; // Timeouts requestTimeoutMs = 30000; // 30 seconds default request timeout // Add a reconnection lock to prevent multiple simultaneous reconnections isReconnecting = false; reconnectTimeoutId = null; constructor() { logger.info("Initializing Bridge..."); // Initialize the auth provider first this.authProvider = authProvider; // Initialize the stdio transport for local communication this.stdioTransport = new StdioServerTransport(); // Initialize the local server this.localServer = new Server(config.LOCAL_SERVER_INFO, { capabilities: { tools: { listChanged: false } } }); // Initialize the remote client (but don't connect yet) this.remoteClient = new Client(config.REMOTE_CLIENT_INFO, { capabilities: config.BRIDGE_CLIENT_CAPABILITIES }); this.serviceAuthorizationHandler = new ServiceAuthorizationHandler(this.remoteClient); // Set timeouts from config this.requestTimeoutMs = config.REQUEST_TIMEOUT_MS || 30000; // Set up signal handlers for graceful shutdown this.setupSignalHandlers(); } /** * Main entry point to start the bridge * * The bridge startup flow follows these steps: * 1. Sets up token change listeners to handle auth events * 2. Attempts to connect to the remote server * - If successful, starts the local server * - If auth is needed, the auth flow is triggered by authProvider * - Once auth completes, the token change listener will reconnect * and start the local server */ async start() { logger.info("Starting Bridge..."); try { // Register the token change listener first this.setupTokenChangeListener(); // Now try to connect to the remote server const connected = await this.connectToRemoteServer(); if (connected) { logger.info("Remote server connection successful, proceeding to start local server"); // Only start the local server if remote connection was successful await this.startLocalServer(); } else { logger.info("Remote server connection requires authentication or failed"); logger.info("Local server startup deferred until authentication completes"); // We'll let the token change listener trigger a reconnect, // which will then start the local server when successful } logger.info("Bridge startup sequence completed"); } catch (error) { logger.error("Error starting Bridge:", error); throw error; } } /** * Set up a listener for token changes to trigger reconnections * * When new tokens are received (either via refresh or browser login), * this handler will reconnect to the remote server with the new tokens. * If the local server isn't running yet, it will be started after * successful authentication. */ setupTokenChangeListener() { this.authProvider.onTokensChanged(async () => { logger.info("Token change detected - attempting reconnect to remote server..."); // Reconnect with the new token const reconnected = await this.connectToRemoteServer(true); if (reconnected) { logger.info("Reconnection with new token successful"); // Start local server if it's not already running if (!this.isLocalServerConnected) { logger.info("Starting local server after successful authentication"); await this.startLocalServer(); } else { logger.info("Local server already running, updating capabilities"); // Update capabilities on the local server if (this.remoteCapabilities) { this.localServer.registerCapabilities(this.remoteCapabilities); } // After a successful token change and reconnect, explicitly fetch and update available tools try { logger.info("Token change: Explicitly refreshing available tools"); const toolsList = await this.remoteClient.listTools(); logger.info(`Token change: Received ${toolsList.tools.length} tools from remote server`); // Force a tools/list notification to clients if (this.isLocalServerConnected) { logger.info("Token change: Notifying local server about updated tools list"); await this.localServer.notification({ method: "tools/list-changed" }); } } catch (error) { logger.error("Error fetching tools after token change:", error); } } } else { logger.error("Failed to reconnect with new token"); } }); } /** * Attempt to connect to the remote server * * This method handles the following: * 1. Creating a new SSE transport with the authProvider * 2. Connecting to the remote server * 3. Handling authentication errors (401) by triggering auth flow * 4. Setting up reconnection on connection failures * * The authProvider is responsible for adding auth tokens to requests * and handling the OAuth flow when needed. * * @param isTokenTriggered Whether this connection attempt was triggered by a token change * @returns boolean indicating if connection was successful */ async connectToRemoteServer(isTokenTriggered = false) { // If already reconnecting, don't start another reconnection if (this.isReconnecting && !isTokenTriggered) { logger.info("Reconnection already in progress, skipping duplicate attempt"); return false; } // Set reconnection flag to prevent simultaneous reconnections this.isReconnecting = true; // Clear any pending reconnect timeout if (this.reconnectTimeoutId) { clearTimeout(this.reconnectTimeoutId); this.reconnectTimeoutId = null; } try { logger.info(`Connecting to remote server at ${config.REMOTE_MCP_URL}...`); logger.info(`Connection attempt triggered by: ${isTokenTriggered ? 'token change' : 'startup/reconnect'}`); // Close any existing connection first if (this.sseTransport || this.isRemoteClientConnected) { try { logger.info("Closing existing remote connection"); await this.remoteClient.close(); this.isRemoteClientConnected = false; this.sseTransport = null; } catch (error) { logger.info("No existing connection to close or error closing", error); } } // Create a new transport instance for this connection attempt logger.info("Creating new SSE transport"); this.sseTransport = new SSEClientTransport(new URL(config.REMOTE_MCP_URL), { authProvider: this.authProvider // This handles OAuth flows automatically }); // DO NOT set onmessage - let the SDK Protocol layer handle message routing internally! // Only set up onerror and onclose for transport health monitoring this.sseTransport.onerror = (error) => { logger.error("[TransportOnError] Remote SSE transport error:", messageFromError(error)); // Check if this is a timeout error const errorMessage = messageFromError(error); if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { logger.warn("[TransportOnError] Timeout detected, connection may be stale"); // Mark as disconnected so the next request will trigger a reconnect this.isRemoteClientConnected = false; // Don't trigger reconnect here to avoid too many simultaneous reconnects // The next call to forwardRequest will attempt to reconnect } // Don't mark as disconnected for other errors, as the connection might recover // Only onclose should trigger a full disconnect for non-timeout errors }; this.sseTransport.onclose = () => { logger.info("Remote SSE transport closed"); this.isRemoteClientConnected = false; // Log disconnection - the SDK will handle pending request rejections // If not shutting down, attempt to reconnect if (!this.isShuttingDown && !this.reconnectTimeoutId) { logger.info("Scheduling reconnect after connection closed..."); // Use reconnect timeout to avoid immediate reconnect this.reconnectTimeoutId = setTimeout(() => { logger.info("Executing scheduled reconnect..."); this.reconnectTimeoutId = null; this.connectToRemoteServer() .catch(err => logger.error("Error during reconnect attempt:", err)) .finally(() => { // Ensure reconnection flag is reset this.isReconnecting = false; }); }, 5000); // 5 second delay before reconnect } }; // Connect to the remote server logger.info("Connecting client to remote server..."); await this.remoteClient.connect(this.sseTransport); // Connection succeeded logger.info("Connected to remote server successfully"); this.isRemoteClientConnected = true; // Set up notification handlers to forward from remote to local this.setupNotificationHandlers(); // Fetch and store remote capabilities this.remoteCapabilities = this.remoteClient.getServerCapabilities() ?? {}; const remoteInfo = this.remoteClient.getServerVersion(); logger.info("Remote server info:", remoteInfo ?? 'N/A'); logger.info("Remote server capabilities:", this.remoteCapabilities); return true; } catch (error) { // Handle connection error logger.error("Error connecting to remote server:", messageFromError(error)); this.isRemoteClientConnected = false; this.sseTransport = null; // If this is an expected 401 Unauthorized during initial connect, // it's normal (the auth flow should be triggered) if (messageFromError(error).includes('Unauthorized') && !isTokenTriggered) { logger.info("Initial 'Unauthorized' error is expected, auth flow should be in progress"); } else { logger.error("Persistent connection failure"); } return false; } finally { // Always reset reconnection flag when done if (!this.reconnectTimeoutId) { this.isReconnecting = false; } } } /** * Start the local stdio server * * This method starts the bridge's local stdio server to accept * connections from local clients (e.g., Claude Desktop). * It sets up event handlers and registers the request handler * that forwards requests to the remote server. */ async startLocalServer() { if (this.isLocalServerConnected) { logger.info("Local server already running"); return; } logger.info("Starting local stdio server..."); try { // Register remote capabilities BEFORE connecting if (this.remoteCapabilities) { logger.info("Registering remote capabilities on local server"); this.localServer.registerCapabilities(this.remoteCapabilities); } else { logger.warn("No remote capabilities available, local server may have limited functionality"); } // Set up the fallback request handler this.localServer.fallbackRequestHandler = this.handleLocalRequest.bind(this); // Set up stdio transport event handlers this.stdioTransport.onclose = () => { logger.info("Local stdio transport closed, shutting down bridge"); this.isLocalServerConnected = false; this.shutdown(); }; this.stdioTransport.onerror = (error) => { logger.error("Local stdio transport error:", error); logger.error("Error details:", messageFromError(error)); }; // Set up ping check to detect client disconnections this.setupClientDisconnectionDetection(); // Connect to stdio logger.info("Connecting local server to stdio transport..."); await this.localServer.connect(this.stdioTransport); this.isLocalServerConnected = true; logger.info("Local stdio server started successfully"); logger.info("Bridge is now fully operational"); } catch (error) { logger.error("Error starting local server:", error); logger.error("Detailed error:", messageFromError(error)); this.isLocalServerConnected = false; throw error; } } /** * Handle a request from the local client * * This is the core message routing function that: * 1. Receives requests from the local stdio client * 2. Routes local tool requests to local handlers * 3. Forwards all other requests to the remote server * 4. Handles special cases like tools/list to merge local tools */ async handleLocalRequest(request) { logger.info(`[Local->Bridge] Handling request: ${request.method}`); try { // Check if this is a tools/call request for a local tool if (isToolCallRequest(request) && toolHandlers[request.params.name]) { logger.info(`[Bridge] Handling local tool: ${request.params.name}`); return toolHandlers[request.params.name](request); } // For all other requests, forward to remote server logger.info(`[Bridge->Remote] Forwarding request: ${request.method}`); // Convert to JSONRPCRequest before forwarding const jsonRpcRequest = { jsonrpc: "2.0", method: request.method, params: request.params, id: 0 // Will be set by forwardRequest }; // If this is a tools/list request, we need to handle it specially to add local tools if (request.method === 'tools/list') { const result = await this.forwardRequest(jsonRpcRequest); // Add our local tools to the list if (result && result.tools && Array.isArray(result.tools)) { logger.info("[Bridge] Adding local tools to tools/list response"); result.tools.push(...localTools); } return result; } // For all other requests, just forward return await this.forwardRequest(jsonRpcRequest); } catch (error) { logger.error(`[Bridge] Error handling request ${request.method}:`, error); // Convert any error to McpError for proper handling by the SDK if (error instanceof McpError) { throw error; } else { throw new McpError(ErrorCode.InternalError, `Error processing request: ${messageFromError(error)}`, { originalError: error }); } } } /** * Forward a request to the remote server, handling connection checks, * timeouts, and secondary authorization flows. * * This method: * 1. Ensures an active connection to the remote server * 2. Forwards the request with proper timeout handling * 3. Processes the result with the ServiceAuthorizationHandler * to detect and handle service auth flows */ async forwardRequest(request) { // Track reconnection attempts to avoid infinite loops let reconnectAttempts = 0; const maxReconnectAttempts = 2; // Maximum number of reconnect attempts per request // Retry loop for handling reconnection on timeout while (reconnectAttempts <= maxReconnectAttempts) { // 1. Ensure connection before proceeding if (!this.isRemoteClientConnected || !this.sseTransport) { logger.info("[Bridge->Remote] No active connection for request, attempting reconnect..."); // Only attempt reconnect if not already reconnecting if (!this.isReconnecting) { await this.connectToRemoteServer(); // Attempt reconnect } else { // Wait for existing reconnection to complete logger.info("[Bridge->Remote] Waiting for existing reconnection to complete..."); await new Promise(resolve => setTimeout(resolve, 1000)); } // After reconnect attempt or wait, check if we're now connected if (!this.isRemoteClientConnected) { // If still not connected after reconnect attempt, throw throw new McpError(ErrorCode.ServerNotInitialized, "Failed to establish connection to remote server before forwarding request", { requestMethod: request.method }); } // If reconnect succeeded, proceed with the request logger.info("[Bridge->Remote] Connection established, proceeding with request."); } // Sanity check - should not happen if connectToRemoteServer worked, but good defense if (!this.remoteClient) { throw new McpError(ErrorCode.InternalError, "Remote client is not initialized."); } // Get the method handler const methodHandler = getHandlerForMethod(request.method); // If no handler is available, the method is not supported by the bridge if (!methodHandler) { throw new McpError(ErrorCode.InternalError, `Method ${request.method} is not supported by this bridge`); } logger.info(`[Bridge->Remote] Forwarding request: ${request.method}`); try { // Get the schema for this method const schema = methodHandler.schema; // Use remoteClient.request with explicit timeout const result = await this.remoteClient.request( // Request payload { method: request.method, params: request.params }, // Use the appropriate schema for this method schema, // Options object - *pass the configured timeout* { timeout: this.requestTimeoutMs }); logger.info(`[Bridge->Remote] Request completed: ${request.method}`); // Process the result if the method handler has a process function let processedResult = result; if (methodHandler.process) { logger.info(`[Bridge->Remote] Processing result for ${request.method}`); processedResult = await methodHandler.process(result); } // This will handle auth if needed or just return the original response return await this.serviceAuthorizationHandler.handleServiceAuthorization(processedResult); } catch (error) { // Check if this is a timeout error const errorMessage = messageFromError(error); if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { reconnectAttempts++; if (reconnectAttempts <= maxReconnectAttempts) { logger.warn(`[Bridge->Remote] Request timed out, attempting reconnect (${reconnectAttempts}/${maxReconnectAttempts})...`); // Force reconnection on timeout this.isRemoteClientConnected = false; // Continue the loop to retry after reconnection continue; } } // Only use default results for methods we know are not supported // We can determine this from the error message/type const errorMsg = messageFromError(error).toLowerCase(); const isUnsupportedMethod = errorMsg.includes('not implemented') || errorMsg.includes('method not found') || errorMsg.includes('not supported'); if (isUnsupportedMethod && methodHandler.defaultResult) { logger.info(`[Bridge->Remote] Method ${request.method} not supported by server, using default result`); return methodHandler.defaultResult; } // For non-timeout errors or if max reconnect attempts reached, just throw logger.error(`[Bridge->Remote] Error during request ${request.method}:`, error); throw error; } } // This should never be reached due to the return or throw in the loop, // but TypeScript needs it for type checking throw new McpError(ErrorCode.InternalError, "Unexpected end of forwardRequest method"); } /** * Register notification handlers for the remote client * This sets up a fallback handler to forward all remote notifications to the local server */ setupNotificationHandlers() { logger.info("[Bridge] Setting up fallback notification handler..."); // Use the fallbackNotificationHandler property for handling all notifications // This is the correct approach rather than misusing setNotificationHandler this.remoteClient.fallbackNotificationHandler = async (notification) => { logger.info(`[Remote->Bridge] Notification received: ${notification.method}`); if (!this.isLocalServerConnected) { logger.warn(`[Remote->Bridge] Cannot forward notification: Local server not connected`); return; } try { // Forward to local server - the notification object is already in the correct format // Ensure notification has the required method property if (typeof notification.method === 'string') { await this.localServer.notification(notification); } else { logger.error(`[Remote->Bridge] Invalid notification format: missing or invalid method property`); } logger.info(`[Remote->Bridge] Forwarded notification: ${notification.method}`); } catch (error) { logger.error(`[Remote->Bridge] Error forwarding notification ${notification.method}:`, error); } }; logger.info("[Bridge] Fallback notification handler set up successfully"); } // Service authorization is now handled by the ServiceAuthorizationHandler class // The SDK handles pending request failures internally /** * Set up signal handlers for graceful shutdown */ setupSignalHandlers() { // Handle SIGINT (Ctrl+C) process.on('SIGINT', () => { logger.info("Received SIGINT signal, shutting down gracefully..."); this.shutdown().catch(err => { logger.error("Error during shutdown:", err); throw new Error(`Shutdown failed: ${messageFromError(err)}`); }); }); // Handle SIGTERM process.on('SIGTERM', () => { logger.info("Received SIGTERM signal, shutting down gracefully..."); this.shutdown().catch(err => { logger.error("Error during shutdown:", err); throw new Error(`Shutdown failed: ${messageFromError(err)}`); }); }); } /** * Set up detection for client disconnection using the ClientConnectionMonitor */ setupClientDisconnectionDetection() { // Stop any existing monitor if (this.clientMonitor) { this.clientMonitor.stop(); this.clientMonitor = null; } logger.info("[Bridge] Setting up client disconnection detection"); // Create a new client monitor this.clientMonitor = new ClientConnectionMonitor(this.localServer, { // When disconnection is detected, shut down the bridge onDisconnection: () => { logger.info("[Bridge] Client disconnection detected by monitor"); this.isLocalServerConnected = false; this.shutdown().catch(err => { logger.error("Error during shutdown after client disconnection:", err); }); } }); // Start monitoring this.clientMonitor.start(); logger.info("[Bridge] Client disconnection detection active"); } /** * Gracefully shut down the bridge */ async shutdown() { if (this.isShuttingDown) { logger.info("Shutdown already in progress"); return; } logger.info("Shutting down Bridge..."); this.isShuttingDown = true; try { // Stop client disconnection monitor if (this.clientMonitor) { logger.info("Stopping client disconnection monitor..."); this.clientMonitor.stop(); this.clientMonitor = null; } // Close the local server if (this.isLocalServerConnected) { logger.info("Closing local server connection..."); await this.localServer.close(); this.isLocalServerConnected = false; logger.info("Local server connection closed"); } // Close the remote client if (this.isRemoteClientConnected) { logger.info("Closing remote client connection..."); await this.remoteClient.close(); this.isRemoteClientConnected = false; logger.info("Remote client connection closed"); } logger.info("Bridge shutdown complete"); } catch (error) { logger.error("Error during shutdown:", error); throw error; } } } //# sourceMappingURL=bridge.js.map