UNPKG

@qwst_desenvolvimento/mcp-connector-http-apikey

Version:

MCP connector for HTTP servers with API key authentication. Bridges MCP clients to HTTP-based MCP servers using x-api-key header authentication.

636 lines (552 loc) 18.2 kB
#!/usr/bin/env node /** * MCP Connector HTTP API Key - Community Edition * MCP connector for HTTP servers with API key authentication * Bridges MCP clients to HTTP-based MCP servers using x-api-key header authentication * * @author Gustavo Cerqueira * @license MIT */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { ListToolsRequestSchema, CallToolRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { createRequire } from 'module'; // ES Modules equivalent of __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Get package version dynamically const require = createRequire(import.meta.url); const packageJson = require('./package.json'); const PACKAGE_VERSION = packageJson.version; class MCPConnectorHttpApiKey { constructor() { this.mcpClient = null; this.mcpServer = null; this.transport = null; this.baseUrl = null; this.apiKey = null; // Generate unique log filename based on server URL this.logFile = this.generateLogFileName(); // Shutdown control this.isShuttingDown = false; this.shutdownTimeout = parseInt(process.env.MCP_SHUTDOWN_TIMEOUT) || 10000; // 10 seconds default this.originalFetch = null; this.initLogging(); } generateLogFileName() { try { const serverUrl = process.env.MCP_SERVER_URL || 'unknown'; // Remove protocol and special characters, keep host and port const sanitized = serverUrl .replace(/^https?:\/\//, '') // Remove http(s):// .replace(/\/.*$/, '') // Remove path after host .replace(/[^a-zA-Z0-9.-]/g, '_'); // Replace special characters with _ return path.join(__dirname, `mcp-connector-${sanitized}.log`); } catch (error) { // Fallback to PID if something goes wrong return path.join(__dirname, `mcp-connector-${process.pid}.log`); } } initLogging() { try { // Only configure log file if MCP_DEBUG is enabled if (process.env.MCP_DEBUG === 'true') { // Clear previous log file if (fs.existsSync(this.logFile)) { fs.writeFileSync(this.logFile, ''); } } this.log('=== MCP Connector HTTP API Key Started (SDK Mode) ==='); this.log(`Version: ${PACKAGE_VERSION}`); this.log(`Timestamp: ${new Date().toISOString()}`); this.log(`Process PID: ${process.pid}`); this.log(`Node Version: ${process.version}`); this.log(`Working Directory: ${process.cwd()}`); this.log(`Server URL: ${process.env.MCP_SERVER_URL}`); this.log(`Log File: ${this.logFile}`); } catch (error) { if (process.env.MCP_DEBUG === 'true') { console.error('Failed to initialize logging:', error.message); } } } log(message, level = 'INFO') { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${level}: ${message}`; // Log to file and console only if DEBUG is active if (process.env.MCP_DEBUG === 'true') { // Log to file try { fs.appendFileSync(this.logFile, logMessage + '\n'); } catch (err) { // Silently fail if cannot write to log } // Log to console console.error(logMessage); } } async initialize() { try { this.log('Starting SDK-based initialization...'); // Validate environment variables const serverUrl = process.env.MCP_SERVER_URL; const apiKey = process.env.MCP_API_KEY; if (!serverUrl) { throw new Error('MCP_SERVER_URL environment variable is required'); } if (!apiKey) { throw new Error('MCP_API_KEY environment variable is required'); } this.log(`Server URL: ${serverUrl}`); this.log(`API Key: ${apiKey.substring(0, 6)}...`); this.baseUrl = new URL(serverUrl); this.apiKey = apiKey; // Setup authentication await this.setupAuthentication(); // Conecta ao servidor remoto usando StreamableHTTP await this.connectToRemoteServer(); // Cria servidor local para expor ao cliente await this.createLocalServer(); this.log('SDK-based initialization completed successfully'); return true; } catch (error) { this.log(`Initialization failed: ${error.message}`, 'ERROR'); this.log(`Error stack: ${error.stack}`, 'ERROR'); throw error; } } async setupAuthentication() { this.log('Setting up authentication...'); // Save original fetch function this.originalFetch = globalThis.fetch; // Override global fetch to inject authentication headers globalThis.fetch = async (url, options = {}) => { try { // Check if this is a request to our server if (url.toString().includes(this.baseUrl.hostname)) { this.log(`Intercepting request to: ${url}`); const headers = new Headers(options.headers); headers.set('x-api-key', this.apiKey); headers.set('Content-Type', 'application/json'); headers.set('Accept', 'application/json, text/event-stream'); headers.set('Connection', 'keep-alive'); headers.set('Cache-Control', 'no-cache'); this.log(`Added authentication headers`); const response = await this.originalFetch(url, { ...options, headers }); this.log(`Response status: ${response.status} ${response.statusText}`); return response; } return this.originalFetch(url, options); } catch (error) { this.log(`Fetch error: ${error.message}`, 'ERROR'); throw error; } }; // Cleanup ao sair do processo process.on('exit', () => { this.log('Process exiting, restoring original fetch'); globalThis.fetch = this.originalFetch; }); this.log('Authentication setup completed'); } async connectToRemoteServer() { this.log('Connecting to remote MCP server using StreamableHTTP...'); try { // Cria o transport StreamableHTTP this.transport = new StreamableHTTPClientTransport(this.baseUrl); // Cria o cliente MCP this.mcpClient = new Client( { name: 'mcp-connector-http-apikey', version: PACKAGE_VERSION, }, { capabilities: { roots: { listChanged: true }, sampling: {}, }, }, ); // Conecta ao servidor remoto await this.mcpClient.connect(this.transport); this.log('Successfully connected to remote MCP server'); // Test connection by listing tools try { const tools = await this.mcpClient.listTools(); const resources = await this.mcpClient.listResources(); const prompts = await this.mcpClient.listPrompts(); this.log(`Remote server has ${tools.tools.length} tools available`); this.log(`Remote server has ${resources.resources.length} resources available`); this.log(`Remote server has ${prompts.prompts.length} prompts available`); } catch (testError) { this.log(`List test failed: ${testError.message}`, 'WARN'); // Continua mesmo se o teste falhar } } catch (error) { this.log(`Failed to connect to remote server: ${error.message}`, 'ERROR'); throw error; } } async createLocalServer() { this.log('Creating local MCP server...'); try { // Cria o servidor local this.mcpServer = new Server( { name: 'mcp-connector-http-apikey', version: PACKAGE_VERSION, }, { capabilities: { tools: {}, prompts: {}, resources: {}, }, }, ); // Configura os handlers que fazem proxy para o servidor remoto this.setupProxyHandlers(); // Configura o transport stdio const stdioTransport = new StdioServerTransport(); // Conecta o servidor await this.mcpServer.connect(stdioTransport); this.log('Local MCP server created and connected successfully'); } catch (error) { this.log(`Failed to create local server: ${error.message}`, 'ERROR'); throw error; } } setupProxyHandlers() { this.log('Setting up proxy handlers...'); // Handler para tools/list this.mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { try { this.log('Proxying tools/list request'); const result = await this.mcpClient.listTools(); this.log(`Returning ${result.tools.length} tools`); return result; } catch (error) { this.log(`Error in tools/list proxy: ${error.message}`, 'ERROR'); return { tools: [] }; } }); // Handler para tools/call this.mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; this.log(`Proxying tools/call request: ${name}`); const result = await this.mcpClient.callTool({ name, arguments: args }); this.log(`Tool call successful: ${name}`); return result; } catch (error) { this.log(`Error in tools/call proxy: ${error.message}`, 'ERROR'); return { content: [ { type: 'text', text: `Error executing tool: ${error.message}`, }, ], isError: true, }; } }); // Handler para prompts/list this.mcpServer.setRequestHandler(ListPromptsRequestSchema, async () => { try { this.log('Proxying prompts/list request'); const result = await this.mcpClient.listPrompts(); this.log(`Returning ${result.prompts.length} prompts`); return result; } catch (error) { this.log(`Error in prompts/list proxy: ${error.message}`, 'ERROR'); return { prompts: [] }; } }); // Handler para prompts/get this.mcpServer.setRequestHandler(GetPromptRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; this.log(`Proxying prompts/get request: ${name}`); const result = await this.mcpClient.getPrompt({ name, arguments: args }); this.log(`Prompt get successful: ${name}`); return result; } catch (error) { this.log(`Error in prompts/get proxy: ${error.message}`, 'ERROR'); return { messages: [ { role: 'user', content: { type: 'text', text: `Error getting prompt: ${error.message}`, }, }, ], }; } }); // Handler para resources/list this.mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => { try { this.log('Proxying resources/list request'); const result = await this.mcpClient.listResources(); this.log(`Returning ${result.resources.length} resources`); return result; } catch (error) { this.log(`Error in resources/list proxy: ${error.message}`, 'ERROR'); return { resources: [] }; } }); // Handler para resources/read this.mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => { try { const { uri } = request.params; this.log(`Proxying resources/read request: ${uri}`); const result = await this.mcpClient.readResource({ uri }); this.log(`Resource read successful: ${uri}`); return result; } catch (error) { this.log(`Error in resources/read proxy: ${error.message}`, 'ERROR'); return { contents: [ { uri, mimeType: 'text/plain', text: `Error reading resource: ${error.message}`, }, ], }; } }); this.log('All proxy handlers configured successfully'); } /** * Graceful shutdown com timeout e cleanup completo */ async shutdown(signal = 'UNKNOWN', timeout = this.shutdownTimeout) { // Prevent multiple simultaneous shutdowns if (this.isShuttingDown) { this.log(`Shutdown already in progress, ignoring ${signal}`, 'WARN'); return; } this.isShuttingDown = true; this.log(`=== Starting graceful shutdown (signal: ${signal}) ===`); const startTime = Date.now(); try { // Cria uma Promise de timeout const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error(`Shutdown timeout after ${timeout}ms`)); }, timeout); }); // Cria uma Promise de cleanup const cleanupPromise = this.performCleanup(); // Executa cleanup com timeout await Promise.race([cleanupPromise, timeoutPromise]); const duration = Date.now() - startTime; this.log(`=== Graceful shutdown completed in ${duration}ms ===`); } catch (error) { const duration = Date.now() - startTime; this.log(`Shutdown error after ${duration}ms: ${error.message}`, 'ERROR'); // Force cleanup mesmo com erro await this.forceCleanup(); } } /** * Executa o cleanup ordenado de todos os recursos */ async performCleanup() { const cleanupSteps = [ { name: 'MCP Client', action: () => this.cleanupMcpClient() }, { name: 'MCP Server', action: () => this.cleanupMcpServer() }, { name: 'Transport', action: () => this.cleanupTransport() }, { name: 'Global Fetch', action: () => this.restoreGlobalFetch() }, ]; for (const step of cleanupSteps) { try { this.log(`Cleaning up: ${step.name}`); await step.action(); this.log(`✓ Cleanup completed: ${step.name}`); } catch (error) { this.log(`✗ Cleanup failed: ${step.name} - ${error.message}`, 'ERROR'); // Continue with next step even if one fails } } } /** * Cleanup forçado quando timeout ou erro */ async forceCleanup() { this.log('Performing force cleanup...', 'WARN'); try { // Force closure without await if (this.mcpClient) { this.mcpClient.close().catch(() => {}); } if (this.mcpServer) { this.mcpServer.close().catch(() => {}); } this.restoreGlobalFetch(); } catch (error) { this.log(`Force cleanup error: ${error.message}`, 'ERROR'); } } /** * Cleanup específico do MCP Client */ async cleanupMcpClient() { if (this.mcpClient) { try { await this.mcpClient.close(); this.mcpClient = null; } catch (error) { this.log(`MCP Client cleanup error: ${error.message}`, 'ERROR'); this.mcpClient = null; // Reset mesmo com erro } } } /** * Cleanup específico do MCP Server */ async cleanupMcpServer() { if (this.mcpServer) { try { await this.mcpServer.close(); this.mcpServer = null; } catch (error) { this.log(`MCP Server cleanup error: ${error.message}`, 'ERROR'); this.mcpServer = null; // Reset mesmo com erro } } } /** * Cleanup específico do Transport */ async cleanupTransport() { if (this.transport) { try { // StreamableHTTPClientTransport may not have explicit close() // but we reset the reference this.transport = null; } catch (error) { this.log(`Transport cleanup error: ${error.message}`, 'ERROR'); this.transport = null; } } } /** * Restaura o fetch global original */ restoreGlobalFetch() { if (this.originalFetch && globalThis.fetch !== this.originalFetch) { try { globalThis.fetch = this.originalFetch; this.log('Global fetch restored'); } catch (error) { this.log(`Error restoring global fetch: ${error.message}`, 'ERROR'); } } } /** * @deprecated Use shutdown() instead */ async close() { this.log('close() method is deprecated, use shutdown() instead', 'WARN'); await this.shutdown('LEGACY_CLOSE'); } } // Main function async function main() { const connector = new MCPConnectorHttpApiKey(); let shutdownInProgress = false; /** * Handler de shutdown melhorado com timeout */ async function handleShutdown(signal) { if (shutdownInProgress) { connector.log(`Shutdown já em progresso, ignorando ${signal}`, 'WARN'); return; } shutdownInProgress = true; connector.log(`Received ${signal}, initiating graceful shutdown...`); try { // Graceful shutdown com timeout de 15 segundos await connector.shutdown(signal, 15000); connector.log(`Shutdown completed successfully for ${signal}`); } catch (error) { connector.log(`Shutdown failed for ${signal}: ${error.message}`, 'ERROR'); } finally { // Force exit after cleanup setTimeout(() => { connector.log(`Force exit after ${signal}`); process.exit(signal === 'SIGTERM' || signal === 'SIGINT' ? 0 : 1); }, 1000); } } try { await connector.initialize(); // Setup de handlers de shutdown process.on('SIGINT', () => handleShutdown('SIGINT')); process.on('SIGTERM', () => handleShutdown('SIGTERM')); // Handlers de erros com shutdown process.on('uncaughtException', async (error) => { connector.log(`Uncaught exception: ${error.message}`, 'ERROR'); connector.log(`Stack trace: ${error.stack}`, 'ERROR'); await handleShutdown('UNCAUGHT_EXCEPTION'); }); process.on('unhandledRejection', async (reason, promise) => { connector.log(`Unhandled rejection at: ${promise} reason: ${reason}`, 'ERROR'); await handleShutdown('UNHANDLED_REJECTION'); }); // Handler for configuration changes or reload process.on('SIGHUP', async () => { connector.log('Received SIGHUP (configuration reload), shutting down for restart...'); await handleShutdown('SIGHUP'); }); // Heartbeat para detectar travamento const heartbeatInterval = setInterval(() => { if (!shutdownInProgress) { // Only show heartbeat when DEBUG is active if (process.env.MCP_DEBUG === 'true') { connector.log('Heartbeat: Connector is alive', 'DEBUG'); } } }, 30000); // A cada 30 segundos // Cleanup heartbeat durante shutdown process.on('exit', () => { clearInterval(heartbeatInterval); }); connector.log('=== MCP Connector HTTP API Key fully initialized and running ==='); } catch (error) { connector.log(`Main execution failed: ${error.message}`, 'ERROR'); connector.log(`Error stack: ${error.stack}`, 'ERROR'); console.error(`Failed to start MCP connector: ${error.message}`); // Attempt cleanup even after initialization failure try { await connector.shutdown('INIT_FAILURE'); } catch (cleanupError) { connector.log(`Cleanup after init failure failed: ${cleanupError.message}`, 'ERROR'); } process.exit(1); } } // Executa se chamado diretamente main().catch((error) => { console.error('Unhandled error:', error); process.exit(1); }); export default MCPConnectorHttpApiKey;