UNPKG

n8n-mcp

Version:

Integration between n8n workflow automation and Model Context Protocol (MCP)

652 lines 26.9 kB
#!/usr/bin/env node "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.startSSEServer = startSSEServer; const express_1 = __importDefault(require("express")); const server_1 = require("./mcp/server"); const sse_session_manager_1 = require("./utils/sse-session-manager"); const logger_1 = require("./utils/logger"); const version_1 = require("./utils/version"); const tools_1 = require("./mcp/tools"); const tools_n8n_manager_1 = require("./mcp/tools-n8n-manager"); const n8n_api_1 = require("./config/n8n-api"); const http_server_1 = require("./http-server"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const dotenv_1 = __importDefault(require("dotenv")); dotenv_1.default.config(); let expressServer; let authToken = null; let sessionManager; let mcpServer; function validateEnvironment() { authToken = (0, http_server_1.loadAuthToken)(); if (!authToken || authToken.trim() === '') { logger_1.logger.error('No authentication token found or token is empty'); console.error('ERROR: AUTH_TOKEN is required for SSE mode and cannot be empty'); console.error('Set AUTH_TOKEN environment variable or AUTH_TOKEN_FILE pointing to a file containing the token'); console.error('Generate AUTH_TOKEN with: openssl rand -base64 32'); process.exit(1); } authToken = authToken.trim(); if (authToken.length < 32) { logger_1.logger.warn('AUTH_TOKEN should be at least 32 characters for security'); console.warn('WARNING: AUTH_TOKEN should be at least 32 characters for security'); } } async function shutdown() { logger_1.logger.info('Shutting down SSE server...'); console.log('Shutting down SSE server...'); if (sessionManager) { sessionManager.shutdown(); } if (expressServer) { expressServer.close(() => { logger_1.logger.info('SSE server closed'); console.log('SSE server closed'); process.exit(0); }); setTimeout(() => { logger_1.logger.error('Forced shutdown after timeout'); process.exit(1); }, 10000); } else { process.exit(0); } } async function startSSEServer() { validateEnvironment(); const app = (0, express_1.default)(); sessionManager = new sse_session_manager_1.SSESessionManager(); mcpServer = new server_1.N8NDocumentationMCPServer(); const trustProxy = process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : 0; if (trustProxy > 0) { app.set('trust proxy', trustProxy); logger_1.logger.info(`Trust proxy enabled with ${trustProxy} hop(s)`); } app.use(express_1.default.json()); app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); next(); }); app.use((req, res, next) => { const allowedOrigin = process.env.CORS_ORIGIN || '*'; res.setHeader('Access-Control-Allow-Origin', allowedOrigin); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, X-Client-ID, X-Auth-Token, X-API-Key'); res.setHeader('Access-Control-Max-Age', '86400'); res.setHeader('Access-Control-Allow-Credentials', 'true'); if (req.method === 'OPTIONS') { logger_1.logger.info('OPTIONS preflight request', { path: req.path, origin: req.headers.origin, headers: req.headers }); res.sendStatus(204); return; } next(); }); app.use((req, res, next) => { const logData = { method: req.method, path: req.path, url: req.url, ip: req.ip, userAgent: req.get('user-agent'), contentLength: req.get('content-length'), headers: process.env.LOG_LEVEL === 'debug' ? req.headers : undefined, query: req.query, isSSERequest: req.headers.accept?.includes('text/event-stream') || false }; logger_1.logger.info(`${req.method} ${req.path}`, logData); if (req.path.includes('/sse') || req.path === '/mcp' || req.headers.accept?.includes('text/event-stream')) { logger_1.logger.info('SSE connection attempt detected', { path: req.path, acceptHeader: req.headers.accept, authHeader: req.headers.authorization ? 'present' : 'missing' }); } next(); }); const authenticateRequest = (req, res, next) => { let token = null; let authMethod = null; logger_1.logger.debug('Authentication attempt', { path: req.path, headers: Object.keys(req.headers), hasAuthHeader: !!req.headers.authorization, hasQuery: !!req.query.token }); const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { token = authHeader.slice(7).trim(); authMethod = 'Bearer'; logger_1.logger.debug('Using Bearer authentication'); } if (!token) { const customHeaderName = process.env.AUTH_HEADER_NAME || 'x-auth-token'; const customHeaderValue = req.headers[customHeaderName.toLowerCase()]; if (customHeaderValue && typeof customHeaderValue === 'string') { token = customHeaderValue.trim(); authMethod = `Custom header (${customHeaderName})`; logger_1.logger.debug(`Using custom header authentication: ${customHeaderName}`); } } if (!token && req.query.token) { token = req.query.token; authMethod = 'Query parameter'; logger_1.logger.debug('Using query parameter authentication'); } if (!token && req.headers['x-api-key']) { token = req.headers['x-api-key']; authMethod = 'API key header'; logger_1.logger.debug('Using API key header authentication'); } if (!token) { logger_1.logger.warn('Authentication failed: No token provided', { ip: req.ip, path: req.path, headers: Object.keys(req.headers), availableAuthMethods: ['Bearer', 'x-auth-token', 'query.token', 'x-api-key'] }); res.status(401).json({ error: 'Unauthorized', message: 'No authentication token provided', hint: 'Use Bearer token, x-auth-token header, query parameter ?token=, or x-api-key header' }); return; } if (token !== authToken) { logger_1.logger.warn('Authentication failed: Invalid token', { ip: req.ip, path: req.path, authMethod: authMethod, tokenReceived: true }); res.status(401).json({ error: 'Unauthorized', message: 'Invalid authentication token' }); return; } logger_1.logger.debug('Authentication successful', { path: req.path, authMethod: authMethod }); next(); }; app.get('/health', (req, res) => { res.json({ status: 'ok', mode: 'sse', version: version_1.PROJECT_VERSION, uptime: Math.floor(process.uptime()), activeSessions: sessionManager.getActiveClientCount(), memory: { used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), unit: 'MB' }, timestamp: new Date().toISOString() }); }); const handleSSE = (req, res) => { const path = req.params.path || 'default'; logger_1.logger.info('SSE endpoint handler invoked', { endpoint: req.path, method: req.method, acceptHeader: req.headers.accept, userAgent: req.headers['user-agent'], path: path }); let clientId; try { clientId = sessionManager.registerClient(res); } catch (error) { logger_1.logger.error('Failed to register SSE client:', error); res.status(503).json({ error: 'Service Unavailable', message: error instanceof Error ? error.message : 'Failed to establish SSE connection' }); return; } logger_1.logger.info(`New SSE connection established: ${clientId} (path: ${path})`, { totalClients: sessionManager.getActiveClientCount(), headers: { accept: req.headers.accept, 'content-type': req.headers['content-type'], 'user-agent': req.headers['user-agent'] } }); const getStringParam = (value) => { if (typeof value === 'string') return value; if (Array.isArray(value) && value.length > 0) return String(value[0]); return undefined; }; const workflowContext = { workflowId: req.headers['x-workflow-id'] || getStringParam(req.query.workflowId), executionId: req.headers['x-execution-id'] || getStringParam(req.query.executionId), nodeId: req.headers['x-node-id'] || getStringParam(req.query.nodeId), nodeName: req.headers['x-node-name'] || getStringParam(req.query.nodeName), runId: req.headers['x-run-id'] || getStringParam(req.query.runId), }; const cleanContext = {}; for (const [key, value] of Object.entries(workflowContext)) { if (value !== undefined) { cleanContext[key] = value; } } if (Object.keys(cleanContext).length > 0) { sessionManager.updateWorkflowContext(clientId, cleanContext); logger_1.logger.info(`Workflow context for client ${clientId}:`, cleanContext); } logger_1.logger.debug('Sending endpoint event', { clientId }); sessionManager.sendToClient(clientId, { event: 'endpoint', data: `/messages?session_id=${clientId}` }); const pingInterval = setInterval(() => { if (!sessionManager.hasClient(clientId)) { clearInterval(pingInterval); return; } sessionManager.sendPing(clientId); }, 30000); req.on('close', () => { clearInterval(pingInterval); logger_1.logger.info(`SSE connection closed: ${clientId}`, { path: path, remainingClients: sessionManager.getActiveClientCount() - 1 }); }); }; app.get('/sse', authenticateRequest, handleSSE); app.get('/mcp', authenticateRequest, handleSSE); app.get('/mcp/:path/sse', authenticateRequest, handleSSE); const handleMessage = async (req, res) => { const sessionId = req.query.session_id; logger_1.logger.info('Message endpoint called', { path: req.path, sessionId, headers: Object.keys(req.headers), body: req.body }); const clientId = sessionId; if (!clientId || !sessionManager.hasClient(clientId)) { res.status(400).json({ error: 'Invalid session', message: 'Client ID not found or session expired' }); return; } try { const jsonRpcRequest = req.body; const workflowContext = sessionManager.getWorkflowContext(clientId); logger_1.logger.debug('Received MCP message:', { clientId, method: jsonRpcRequest.method, id: jsonRpcRequest.id, workflowContext }); let response; switch (jsonRpcRequest.method) { case 'initialize': response = { jsonrpc: '2.0', result: { protocolVersion: types_js_1.DEFAULT_NEGOTIATED_PROTOCOL_VERSION, capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'n8n-documentation-mcp', version: version_1.PROJECT_VERSION } }, id: jsonRpcRequest.id }; break; case 'tools/list': const tools = [...tools_1.n8nDocumentationToolsFinal]; if ((0, n8n_api_1.isN8nApiConfigured)()) { tools.push(...tools_n8n_manager_1.n8nManagementTools); } response = { jsonrpc: '2.0', result: { tools }, id: jsonRpcRequest.id }; break; case 'tools/call': const toolName = jsonRpcRequest.params?.name; const toolArgs = jsonRpcRequest.params?.arguments || {}; logger_1.logger.debug('Tool call details:', { toolName, toolArgs, toolArgsType: typeof toolArgs, toolArgsKeys: Object.keys(toolArgs), rawParams: jsonRpcRequest.params }); try { const result = await mcpServer.executeTool(toolName, toolArgs); response = { jsonrpc: '2.0', result: { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ] }, id: jsonRpcRequest.id }; } catch (error) { logger_1.logger.error(`Error executing tool ${toolName}:`, error); response = { jsonrpc: '2.0', error: { code: -32603, message: `Error executing tool ${toolName}: ${error instanceof Error ? error.message : String(error)}` }, id: jsonRpcRequest.id }; } break; case 'resources/list': response = { jsonrpc: '2.0', result: { resources: [] }, id: jsonRpcRequest.id }; break; case 'resources/read': response = { jsonrpc: '2.0', error: { code: -32601, message: 'Resource reading not implemented' }, id: jsonRpcRequest.id }; break; case 'prompts/list': response = { jsonrpc: '2.0', result: { prompts: [] }, id: jsonRpcRequest.id }; break; case 'prompts/get': response = { jsonrpc: '2.0', error: { code: -32601, message: 'Prompt retrieval not implemented' }, id: jsonRpcRequest.id }; break; default: response = { jsonrpc: '2.0', error: { code: -32601, message: `Method not found: ${jsonRpcRequest.method}` }, id: jsonRpcRequest.id }; } sessionManager.sendMCPMessage(clientId, response); res.json({ status: 'ok', messageId: jsonRpcRequest.id }); } catch (error) { logger_1.logger.error('Error processing MCP message:', error); res.status(500).json({ error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error' }); } }; const handleLegacyMCP = async (req, res) => { try { const jsonRpcRequest = req.body; logger_1.logger.debug('Received legacy MCP request:', { method: jsonRpcRequest.method }); let response; switch (jsonRpcRequest.method) { case 'initialize': response = { jsonrpc: '2.0', result: { protocolVersion: types_js_1.DEFAULT_NEGOTIATED_PROTOCOL_VERSION, capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'n8n-documentation-mcp', version: version_1.PROJECT_VERSION } }, id: jsonRpcRequest.id }; break; case 'tools/list': const tools = [...tools_1.n8nDocumentationToolsFinal]; if ((0, n8n_api_1.isN8nApiConfigured)()) { tools.push(...tools_n8n_manager_1.n8nManagementTools); } response = { jsonrpc: '2.0', result: { tools }, id: jsonRpcRequest.id }; break; case 'tools/call': const toolName = jsonRpcRequest.params?.name; const toolArgs = jsonRpcRequest.params?.arguments || {}; logger_1.logger.debug('Tool call details:', { toolName, toolArgs, toolArgsType: typeof toolArgs, toolArgsKeys: Object.keys(toolArgs), rawParams: jsonRpcRequest.params }); try { const result = await mcpServer.executeTool(toolName, toolArgs); response = { jsonrpc: '2.0', result: { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ] }, id: jsonRpcRequest.id }; } catch (error) { logger_1.logger.error(`Error executing tool ${toolName}:`, error); response = { jsonrpc: '2.0', error: { code: -32603, message: `Error executing tool ${toolName}: ${error instanceof Error ? error.message : String(error)}` }, id: jsonRpcRequest.id }; } break; case 'resources/list': response = { jsonrpc: '2.0', result: { resources: [] }, id: jsonRpcRequest.id }; break; case 'resources/read': response = { jsonrpc: '2.0', error: { code: -32601, message: 'Resource reading not implemented' }, id: jsonRpcRequest.id }; break; case 'prompts/list': response = { jsonrpc: '2.0', result: { prompts: [] }, id: jsonRpcRequest.id }; break; case 'prompts/get': response = { jsonrpc: '2.0', error: { code: -32601, message: 'Prompt retrieval not implemented' }, id: jsonRpcRequest.id }; break; default: response = { jsonrpc: '2.0', error: { code: -32601, message: `Method not found: ${jsonRpcRequest.method}` }, id: jsonRpcRequest.id }; } res.json(response); } catch (error) { logger_1.logger.error('Legacy MCP request error:', error); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', data: error instanceof Error ? error.message : undefined }, id: null }); } }; app.post('/messages', authenticateRequest, handleMessage); app.post('/mcp/message', authenticateRequest, handleMessage); app.post('/mcp/:path/message', authenticateRequest, handleMessage); app.post('/mcp', authenticateRequest, handleLegacyMCP); app.post('/mcp/:path', authenticateRequest, handleLegacyMCP); app.post('/sse', authenticateRequest, handleLegacyMCP); app.use((req, res, next) => { const isKnownRoute = ['/health', '/sse', '/mcp', '/mcp/message'].some(route => req.path === route || req.path.startsWith(route + '/')); if (!isKnownRoute) { logger_1.logger.warn('Unmatched request', { method: req.method, path: req.path, url: req.url, headers: Object.keys(req.headers), hasAuth: !!req.headers.authorization, ip: req.ip }); } next(); }); app.use((req, res) => { res.status(404).json({ error: 'Not found', message: `Cannot ${req.method} ${req.path}` }); }); app.use((err, req, res, next) => { logger_1.logger.error('Express error handler:', err); if (!res.headersSent) { res.status(500).json({ error: 'Internal server error', message: process.env.NODE_ENV === 'development' ? err.message : 'An error occurred' }); } }); const port = parseInt(process.env.PORT || '3000'); const host = process.env.HOST || '0.0.0.0'; expressServer = app.listen(port, host, () => { logger_1.logger.info(`n8n MCP SSE Server started`, { port, host }); console.log(`n8n MCP SSE Server running on ${host}:${port}`); console.log(`Health check: http://localhost:${port}/health`); console.log(`SSE endpoints:`); console.log(` - http://localhost:${port}/sse`); console.log(` - http://localhost:${port}/mcp`); console.log(` - http://localhost:${port}/mcp/{path}/sse`); console.log(`Message endpoints:`); console.log(` - http://localhost:${port}/messages?session_id={session_id}`); console.log(` - http://localhost:${port}/mcp/message (legacy)`); console.log('\nPress Ctrl+C to stop the server'); }); expressServer.on('error', (error) => { if (error.code === 'EADDRINUSE') { logger_1.logger.error(`Port ${port} is already in use`); console.error(`ERROR: Port ${port} is already in use`); process.exit(1); } else { logger_1.logger.error('Server error:', error); console.error('Server error:', error); process.exit(1); } }); process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); process.on('uncaughtException', (error) => { logger_1.logger.error('Uncaught exception:', error); console.error('Uncaught exception:', error); shutdown(); }); process.on('unhandledRejection', (reason, promise) => { logger_1.logger.error('Unhandled rejection:', reason); console.error('Unhandled rejection at:', promise, 'reason:', reason); shutdown(); }); } if (require.main === module) { startSSEServer().catch(error => { logger_1.logger.error('Failed to start SSE server:', error); console.error('Failed to start SSE server:', error); process.exit(1); }); } //# sourceMappingURL=sse-server.js.map