@civic/nexus-bridge
Version:
Stdio <-> HTTP/SSE MCP bridge with Civic auth handling
617 lines • 30.3 kB
JavaScript
/**
* 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