@civic/hub-bridge
Version:
Stdio <-> HTTP/SSE MCP bridge with Civic auth handling
204 lines • 8.8 kB
JavaScript
// Hub Bridge - MCP Bridge with Civic Auth
// This is a rewritten version of the hub-bridge package
import { createStdioPassthroughProxy } from "@civic/passthrough-mcp-server";
import { REMOTE_MCP_URL } from "./config/index.js";
import { AuthenticationHook } from "./hooks/index.js";
import { CLIAuthProviderSingleton } from "./auth/cli-auth-provider-singleton.js";
import * as configVars from './config/index.js';
import { handleCliArguments, getVersion } from './cli/index.js';
import { logger } from "./utils/index.js";
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
import { HubAuthFailureInterceptor } from "./hooks/hub-auth-failure-interceptor.js";
import { LocalToolsHook } from "./hooks/local-tools-hook.js";
import { InitializeLogHook } from "./hooks/initialize-log-hook.js";
import { ConfigManager } from "./lib/ConfigManager.js";
import { createHubBridgeStreamableHttpTransport } from "./transport/index.js";
// Global state for graceful shutdown
let proxy = null;
let isShuttingDown = false; // Prevent double shutdown calls
// Log configuration values
const logConfig = async () => {
const version = await getVersion();
logger.info(`Civic Hub Bridge v${version} (Node ${process.version})`);
logger.info('Configuration:');
logger.info('MCP_REMOTE_URL:', configVars.REMOTE_MCP_URL);
logger.info('CLIENT_ID:', configVars.CLIENT_ID);
logger.info('NO_LOGIN:', configVars.NO_LOGIN);
logger.info('NO_AUTH_CAPTURE:', configVars.NO_AUTH_CAPTURE);
};
// Redirect console.log to stderr to avoid interfering with MCP JSON messages
// Note: Our custom logger (utils/logger.ts) already writes directly to stderr, but
// these redirects ensure that any direct console.log calls or third-party libraries
// that use console methods also have their output redirected to stderr. This provides
// a comprehensive solution where ALL output is consistently sent to stderr regardless
// of its source, maintaining clean stdout for the MCP protocol.
console.log = (...args) => {
process.stderr.write(`LOG: ${args.join(' ')}\n`);
};
console.error = (...args) => {
process.stderr.write(`ERROR: ${args.join(' ')}\n`);
};
console.warn = (...args) => {
process.stderr.write(`WARN: ${args.join(' ')}\n`);
};
console.info = (...args) => {
process.stderr.write(`INFO: ${args.join(' ')}\n`);
};
/**
* Graceful shutdown handler
*/
async function shutdown(signal) {
if (isShuttingDown)
return;
isShuttingDown = true;
logger.info(`\nReceived ${signal}. Shutting down gracefully...`);
if (proxy) {
try {
await proxy.stop();
logger.info("Proxy stopped successfully.");
}
catch (error) {
logger.error("Error stopping proxy:", error);
}
}
logger.info("Shutdown complete.");
// eslint-disable-next-line no-process-exit
process.exit(0);
// Node will exit naturally after all event handlers complete
}
/**
* Main function to start the passthrough MCP server
*/
async function main() {
// Handle CLI arguments (version, help, install)
const shouldExit = await handleCliArguments();
if (shouldExit)
return;
// Log configuration information
await logConfig();
// Load configuration
logger.info("Loading configuration...");
const configmanager = ConfigManager.getInstance();
const currentProfile = await configmanager.getCurrentProfileAlias();
logger.info(`Current profile: ${currentProfile}`);
try {
// Get the singleton auth provider
const authProvider = CLIAuthProviderSingleton.getInstance();
const authenticationHook = new AuthenticationHook(authProvider);
const hubAuthFailureInterceptor = new HubAuthFailureInterceptor();
const stdioProxy = await createStdioPassthroughProxy({
hooks: [
new InitializeLogHook(),
new LocalToolsHook(),
authenticationHook,
hubAuthFailureInterceptor,
// new SessionRecoveryHook()
],
target: {
transportType: 'custom',
transportFactory: () => createHubBridgeStreamableHttpTransport(currentProfile)
},
autoStart: false
});
proxy = stdioProxy;
const continueJobCallback = async (jobId) => {
return stdioProxy.proxyContext.target.request({
method: "tools/call",
params: {
name: "continue_job",
arguments: {
jobId: jobId
}
}
}, CallToolResultSchema);
};
authenticationHook.setContinueJobCallback(continueJobCallback);
hubAuthFailureInterceptor.setTransportInterface(stdioProxy.proxyContext.target);
// proxy.server.addTool({
// name: localTool.name,
// description: localTool.description,
// parameters: z.object(zodProperties),
// execute: async (_args) => {
// // Import the handler dynamically to avoid circular imports
// const { toolHandlers } = await import('./tools/index.js');
// const handler = toolHandlers[localTool.name];
// if (handler) {
// // Create a proper MCP request object
// const request = CallToolRequestSchema.parse({
// jsonrpc: "2.0",
// id: 0,
// method: "tools/call",
// params: {
// name: localTool.name,
// arguments: _args || {}
// }
// });
//
// // Call the handler with the properly formatted request
// const result = await handler(request);
//
// // Convert from CallToolResult to FastMCP expected format
// if (result.content && result.content.length > 0) {
// // Return the first text content as a simple string
// const firstContent = result.content[0];
// if (firstContent.type === 'text') {
// return firstContent.text;
// }
// }
//
// return result.isError ? "Operation failed" : "Operation completed";
// }
// throw new Error(`No handler found for local tool: ${localTool.name}`);
// }
// });
// }
// Start manually later
await proxy.start();
// trigger auth // remove if required.
// await stdioProxy.proxyContext.target.ping();
// TODO: Integrate ClientConnectionMonitor for detecting client disconnection
// The ClientConnectionMonitor class is available in utils/client-detection.ts and needs to be
// integrated with the FastMCP server sessions. Need to:
// 1. Find the correct way to access MCP Server instances from FastMCP sessions
// 2. Monitor for new client connections and start ClientConnectionMonitor for each
// 3. Handle cleanup when sessions disconnect
// 4. Implement graceful shutdown when client disconnection is detected
logger.info(`Passthrough Stdio MCP Server running with httpStream transport, connecting to target at ${REMOTE_MCP_URL}`);
}
catch (error) {
logger.error("Error details:", error);
logger.error("Failed to start server1: " + error);
throw new Error(`Failed to start server: ${error}`);
}
}
// Process signal handlers for graceful shutdown
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
// Basic error handling
process.on('uncaughtException', (error, origin) => {
logger.error(`\nFATAL UNCAUGHT EXCEPTION (${origin}):`, error);
// Attempt graceful shutdown, but it might fail if state is corrupted
shutdown('uncaughtException').catch(() => {
logger.error('Failed to shutdown gracefully after uncaught exception');
throw error;
});
});
process.on('unhandledRejection', (reason) => {
logger.error('\nFATAL UNHANDLED REJECTION:');
logger.error('Reason:', reason);
// Attempt graceful shutdown
shutdown('unhandledRejection').catch(() => {
logger.error('Failed to shutdown gracefully after unhandled rejection');
throw reason instanceof Error ? reason : new Error(String(reason));
});
});
// Export main function for CLI usage
export { main };
// Start the server if this file is run directly
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
logger.error("Unhandled error: " + error);
throw new Error(`Failed to start: ${error}`);
});
}
//# sourceMappingURL=index.js.map