@civic/hub-bridge
Version:
Stdio <-> HTTP/SSE MCP bridge with Civic auth handling
192 lines • 8.53 kB
JavaScript
// Hub Bridge - MCP Bridge with Civic Auth
// This is a rewritten version of the hub-bridge package
import { createPassthroughProxy } from "@civic/passthrough-mcp-server";
import { getConfig } from "./config/index.js";
import { AuthenticationHook, SessionRecoveryHook } from "./hooks/index.js";
import { CLIAuthProviderSingleton } from "./auth/cli-auth-provider-singleton.js";
import { localTools } from './tools/index.js';
import { z } from 'zod';
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import * as configVars from './config/index.js';
import { handleCliArguments, getVersion } from './cli/index.js';
import { logger } from "./utils/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 proxyConfig = getConfig();
logger.info("Config loaded:", JSON.stringify(proxyConfig, null, 2));
try {
// Get the singleton auth provider
const authProvider = CLIAuthProviderSingleton.getInstance();
proxy = await createPassthroughProxy({
...proxyConfig,
hooks: [
new AuthenticationHook(authProvider),
new SessionRecoveryHook()
],
autoStart: false
});
// Add local tools to the FastMCP server
for (const localTool of localTools) {
// Convert JSON Schema properties to Zod schema
const zodProperties = {};
if (localTool.inputSchema.properties) {
for (const [key, prop] of Object.entries(localTool.inputSchema.properties)) {
const property = prop;
if (property.type === 'string') {
zodProperties[key] = z.string();
}
else if (property.type === 'number') {
zodProperties[key] = z.number();
}
else if (property.type === 'boolean') {
zodProperties[key] = z.boolean();
}
// Add more types as needed
}
}
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 ToolCallResult 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();
// 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 MCP Server running with ${proxyConfig.transportType} transport ${proxyConfig.transportType !== "stdio" ? ` on port ${proxyConfig.port}` : ""}, connecting to target at ${proxyConfig.target.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