UNPKG

heyreach-mcp-server

Version:

Modern MCP server for HeyReach LinkedIn automation with dual transport support (stdio + HTTP streaming) and header authentication

270 lines (269 loc) 10 kB
#!/usr/bin/env node import express from 'express'; import cors from 'cors'; import { randomUUID } from 'node:crypto'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { HeyReachMcpServer } from './server.js'; // Map to store transports by session ID const transports = {}; /** * Extract API key from URL path or headers */ function extractApiKey(req) { // First try to get API key from URL path (existing method) const pathApiKey = extractApiKeyFromPath(req.path); if (pathApiKey) { return pathApiKey; } // Then try to get API key from headers const headerApiKey = req.headers['x-api-key'] || req.headers['authorization']; if (headerApiKey) { // Handle Authorization header with Bearer prefix if (typeof headerApiKey === 'string' && headerApiKey.startsWith('Bearer ')) { return headerApiKey.substring(7); } return headerApiKey; } return null; } /** * Extract API key from URL path */ function extractApiKeyFromPath(path) { const match = path.match(/^\/mcp\/([^\/]+)(?:\/.*)?$/); return match ? match[1] : null; } /** * Create Express app with CORS and middleware */ function createApp() { const app = express(); // Enable CORS for browser-based clients app.use(cors({ origin: '*', // Configure appropriately for production exposedHeaders: ['Mcp-Session-Id'], allowedHeaders: ['Content-Type', 'mcp-session-id', 'x-api-key', 'authorization'], })); app.use(express.json()); return app; } /** * Create HeyReach MCP server instance */ function createHeyReachServer(apiKey, baseUrl) { const config = { apiKey, baseUrl }; return new HeyReachMcpServer(config); } /** * Handle MCP requests with session management */ async function handleMcpRequest(req, res) { try { // Extract API key from URL path const apiKey = extractApiKey(req); if (!apiKey) { res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: API key required in URL path (/mcp/{API_KEY}) or header (X-API-Key or Authorization)', }, id: null, }); return; } // Check for existing session ID const sessionId = req.headers['mcp-session-id']; let sessionTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport sessionTransport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request // Debug logging for environment variables console.log('🔧 Environment Variables Debug:'); console.log('ALLOWED_HOSTS:', process.env.ALLOWED_HOSTS); console.log('ENABLE_DNS_REBINDING_PROTECTION:', process.env.ENABLE_DNS_REBINDING_PROTECTION); const allowedHosts = process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',').map(host => host.trim()) : ['127.0.0.1', 'localhost', 'localhost:3000', 'localhost:3001', 'localhost:3002', 'localhost:3003', 'localhost:3004', 'localhost:3005']; console.log('🔧 Parsed allowedHosts:', allowedHosts); console.log('🔧 DNS Rebinding Protection:', process.env.ENABLE_DNS_REBINDING_PROTECTION !== 'false'); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { // Store the transport by session ID transports[sessionId] = sessionTransport; }, // Enable DNS rebinding protection for security enableDnsRebindingProtection: process.env.ENABLE_DNS_REBINDING_PROTECTION !== 'false', allowedHosts, }); // Create HeyReach MCP server const heyReachServer = createHeyReachServer(apiKey); const server = heyReachServer.getServer(); sessionTransport = { transport, server, heyReachServer }; // Clean up transport when closed transport.onclose = () => { if (transport.sessionId) { delete transports[transport.sessionId]; } }; // Connect to the MCP server await server.connect(transport); console.error(`HeyReach MCP HTTP Server session initialized: ${transport.sessionId}`); console.error(`API Key: ${apiKey.substring(0, 8)}...`); } else { // Invalid request res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided', }, id: null, }); return; } // Handle the request await sessionTransport.transport.handleRequest(req, res, req.body); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); } } } /** * Handle session requests (GET for SSE, DELETE for termination) */ async function handleSessionRequest(req, res) { const sessionId = req.headers['mcp-session-id']; if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID'); return; } const sessionTransport = transports[sessionId]; await sessionTransport.transport.handleRequest(req, res); } /** * Start HTTP streaming server */ export async function startHttpServer(port = 3000) { const app = createApp(); // Handle POST requests for client-to-server communication // Support both URL path authentication (/mcp/{API_KEY}) and header authentication (/mcp) app.post('/mcp/:apiKey?', handleMcpRequest); app.post('/mcp', handleMcpRequest); // Handle GET requests for server-to-client notifications via SSE app.get('/mcp/:apiKey?', handleSessionRequest); app.get('/mcp', handleSessionRequest); // Handle DELETE requests for session termination app.delete('/mcp/:apiKey?', handleSessionRequest); app.delete('/mcp', handleSessionRequest); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), sessions: Object.keys(transports).length }); }); // Root endpoint with usage information app.get('/', (req, res) => { res.json({ name: 'HeyReach MCP Server', version: '2.0.4', description: 'HTTP Streaming MCP Server for HeyReach LinkedIn automation with header authentication', usage: { endpoint: '/mcp/{API_KEY} or /mcp with header authentication', methods: ['POST', 'GET', 'DELETE'], authentication: [ 'URL path: /mcp/{API_KEY}', 'Header: X-API-Key: {API_KEY}', 'Header: Authorization: Bearer {API_KEY}' ], example: '/mcp/QGUYbd7r... or /mcp with X-API-Key header' }, documentation: 'https://github.com/bcharleson/heyreach-mcp-server' }); }); return new Promise((resolve, reject) => { const server = app.listen(port, (error) => { if (error) { reject(error); } else { console.error(`HeyReach MCP HTTP Server listening on port ${port}`); console.error(`Usage: POST/GET/DELETE to /mcp/{API_KEY} or /mcp with header authentication`); console.error(`Header auth: X-API-Key: {API_KEY} or Authorization: Bearer {API_KEY}`); console.error(`Health check: GET /health`); resolve(); } }); // Graceful shutdown process.on('SIGINT', () => { console.error('Received SIGINT, shutting down gracefully...'); server.close(() => { // Clean up all transports Object.values(transports).forEach(({ transport, server }) => { transport.close(); server.close(); }); process.exit(0); }); }); process.on('SIGTERM', () => { console.error('Received SIGTERM, shutting down gracefully...'); server.close(() => { // Clean up all transports Object.values(transports).forEach(({ transport, server }) => { transport.close(); server.close(); }); process.exit(0); }); }); }); } /** * Main function for HTTP server mode */ export async function main() { try { const port = parseInt(process.env.PORT || '3000', 10); await startHttpServer(port); } catch (error) { console.error('Failed to start HeyReach MCP HTTP Server:', error); process.exit(1); } } // Start the server if this file is run directly import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const isMainModule = process.argv[1] === __filename; if (isMainModule) { main().catch((error) => { console.error('Unhandled error:', error); process.exit(1); }); }