@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
JavaScript
/**
* 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;