http-server.tsโข20.6 kB
#!/usr/bin/env node
/**
* Fixed HTTP server for n8n-MCP that properly handles StreamableHTTPServerTransport initialization
* This implementation ensures the transport is properly initialized before handling requests
*/
import express from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { n8nDocumentationToolsFinal } from './mcp/tools';
import { n8nManagementTools } from './mcp/tools-n8n-manager';
import { N8NDocumentationMCPServer } from './mcp/server';
import { logger } from './utils/logger';
import { AuthManager } from './utils/auth';
import { PROJECT_VERSION } from './utils/version';
import { isN8nApiConfigured } from './config/n8n-api';
import dotenv from 'dotenv';
import { readFileSync } from 'fs';
import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector';
import {
negotiateProtocolVersion,
logProtocolNegotiation,
N8N_PROTOCOL_VERSION
} from './utils/protocol-version';
dotenv.config();
/**
* MCP tool response format with optional structured content
*/
interface MCPToolResponse {
content: Array<{
type: 'text';
text: string;
}>;
structuredContent?: unknown;
}
let expressServer: any;
let authToken: string | null = null;
/**
* Load auth token from environment variable or file
*/
export function loadAuthToken(): string | null {
// First, try AUTH_TOKEN environment variable
if (process.env.AUTH_TOKEN) {
logger.info('Using AUTH_TOKEN from environment variable');
return process.env.AUTH_TOKEN;
}
// Then, try AUTH_TOKEN_FILE
if (process.env.AUTH_TOKEN_FILE) {
try {
const token = readFileSync(process.env.AUTH_TOKEN_FILE, 'utf-8').trim();
logger.info(`Loaded AUTH_TOKEN from file: ${process.env.AUTH_TOKEN_FILE}`);
return token;
} catch (error) {
logger.error(`Failed to read AUTH_TOKEN_FILE: ${process.env.AUTH_TOKEN_FILE}`, error);
console.error(`ERROR: Failed to read AUTH_TOKEN_FILE: ${process.env.AUTH_TOKEN_FILE}`);
console.error(error instanceof Error ? error.message : 'Unknown error');
return null;
}
}
return null;
}
/**
* Validate required environment variables
*/
function validateEnvironment() {
// Load auth token from env var or file
authToken = loadAuthToken();
if (!authToken || authToken.trim() === '') {
logger.error('No authentication token found or token is empty');
console.error('ERROR: AUTH_TOKEN is required for HTTP 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);
}
// Update authToken to trimmed version
authToken = authToken.trim();
if (authToken.length < 32) {
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');
}
// Check for default token and show prominent warnings
if (authToken === 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh') {
logger.warn('โ ๏ธ SECURITY WARNING: Using default AUTH_TOKEN - CHANGE IMMEDIATELY!');
logger.warn('Generate secure token with: openssl rand -base64 32');
// Only show console warnings in HTTP mode
if (process.env.MCP_MODE === 'http') {
console.warn('\nโ ๏ธ SECURITY WARNING โ ๏ธ');
console.warn('Using default AUTH_TOKEN - CHANGE IMMEDIATELY!');
console.warn('Generate secure token: openssl rand -base64 32');
console.warn('Update via Railway dashboard environment variables\n');
}
}
}
/**
* Graceful shutdown handler
*/
async function shutdown() {
logger.info('Shutting down HTTP server...');
console.log('Shutting down HTTP server...');
if (expressServer) {
expressServer.close(() => {
logger.info('HTTP server closed');
console.log('HTTP server closed');
process.exit(0);
});
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
} else {
process.exit(0);
}
}
export async function startFixedHTTPServer() {
validateEnvironment();
const app = express();
// Configure trust proxy for correct IP logging behind reverse proxies
const trustProxy = process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : 0;
if (trustProxy > 0) {
app.set('trust proxy', trustProxy);
logger.info(`Trust proxy enabled with ${trustProxy} hop(s)`);
}
// CRITICAL: Don't use any body parser - StreamableHTTPServerTransport needs raw stream
// Security headers
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();
});
// CORS configuration
app.use((req, res, next) => {
const allowedOrigin = process.env.CORS_ORIGIN || '*';
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept');
res.setHeader('Access-Control-Max-Age', '86400');
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
next();
});
// Request logging
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`, {
ip: req.ip,
userAgent: req.get('user-agent'),
contentLength: req.get('content-length')
});
next();
});
// Create a single persistent MCP server instance
const mcpServer = new N8NDocumentationMCPServer();
logger.info('Created persistent MCP server instance');
// Root endpoint with API information
app.get('/', (req, res) => {
const port = parseInt(process.env.PORT || '3000');
const host = process.env.HOST || '0.0.0.0';
const baseUrl = detectBaseUrl(req, host, port);
const endpoints = formatEndpointUrls(baseUrl);
res.json({
name: 'n8n Documentation MCP Server',
version: PROJECT_VERSION,
description: 'Model Context Protocol server providing comprehensive n8n node documentation and workflow management',
endpoints: {
health: {
url: endpoints.health,
method: 'GET',
description: 'Health check and status information'
},
mcp: {
url: endpoints.mcp,
method: 'GET/POST',
description: 'MCP endpoint - GET for info, POST for JSON-RPC'
}
},
authentication: {
type: 'Bearer Token',
header: 'Authorization: Bearer <token>',
required_for: ['POST /mcp']
},
documentation: 'https://github.com/czlonkowski/n8n-mcp'
});
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
mode: 'http-fixed',
version: PROJECT_VERSION,
uptime: Math.floor(process.uptime()),
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
unit: 'MB'
},
timestamp: new Date().toISOString()
});
});
// Version endpoint
app.get('/version', (req, res) => {
res.json({
version: PROJECT_VERSION,
buildTime: new Date().toISOString(),
tools: n8nDocumentationToolsFinal.map(t => t.name),
commit: process.env.GIT_COMMIT || 'unknown'
});
});
// Test tools endpoint
app.get('/test-tools', async (req, res) => {
try {
const result = await mcpServer.executeTool('get_node_essentials', { nodeType: 'nodes-base.httpRequest' });
res.json({ status: 'ok', hasData: !!result, toolCount: n8nDocumentationToolsFinal.length });
} catch (error) {
res.json({ status: 'error', message: error instanceof Error ? error.message : 'Unknown error' });
}
});
// MCP information endpoint (no auth required for discovery)
app.get('/mcp', (req, res) => {
res.json({
description: 'n8n Documentation MCP Server',
version: PROJECT_VERSION,
endpoints: {
mcp: {
method: 'POST',
path: '/mcp',
description: 'Main MCP JSON-RPC endpoint',
authentication: 'Bearer token required'
},
health: {
method: 'GET',
path: '/health',
description: 'Health check endpoint',
authentication: 'None'
},
root: {
method: 'GET',
path: '/',
description: 'API information',
authentication: 'None'
}
},
documentation: 'https://github.com/czlonkowski/n8n-mcp'
});
});
// Main MCP endpoint - handle each request with custom transport handling
app.post('/mcp', async (req: express.Request, res: express.Response): Promise<void> => {
const startTime = Date.now();
// Enhanced authentication check with specific logging
const authHeader = req.headers.authorization;
// Check if Authorization header is missing
if (!authHeader) {
logger.warn('Authentication failed: Missing Authorization header', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'no_auth_header'
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return;
}
// Check if Authorization header has Bearer prefix
if (!authHeader.startsWith('Bearer ')) {
logger.warn('Authentication failed: Invalid Authorization header format (expected Bearer token)', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_auth_format',
headerPrefix: authHeader.substring(0, Math.min(authHeader.length, 10)) + '...' // Log first 10 chars for debugging
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return;
}
// Extract token and trim whitespace
const token = authHeader.slice(7).trim();
// SECURITY: Use timing-safe comparison to prevent timing attacks
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02)
const isValidToken = authToken &&
AuthManager.timingSafeCompare(token, authToken);
if (!isValidToken) {
logger.warn('Authentication failed: Invalid token', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_token'
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return;
}
try {
// Instead of using StreamableHTTPServerTransport, we'll handle the request directly
// This avoids the initialization issues with the transport
// Collect the raw body
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const jsonRpcRequest = JSON.parse(body);
logger.debug('Received JSON-RPC request:', { method: jsonRpcRequest.method });
// Handle the request based on method
let response;
switch (jsonRpcRequest.method) {
case 'initialize':
// Negotiate protocol version for this client/request
const negotiationResult = negotiateProtocolVersion(
jsonRpcRequest.params?.protocolVersion,
jsonRpcRequest.params?.clientInfo,
req.get('user-agent'),
req.headers
);
logProtocolNegotiation(negotiationResult, logger, 'HTTP_SERVER_INITIALIZE');
response = {
jsonrpc: '2.0',
result: {
protocolVersion: negotiationResult.version,
capabilities: {
tools: {},
resources: {}
},
serverInfo: {
name: 'n8n-documentation-mcp',
version: PROJECT_VERSION
}
},
id: jsonRpcRequest.id
};
break;
case 'tools/list':
// Use the proper tool list that includes management tools when configured
const tools = [...n8nDocumentationToolsFinal];
// Add management tools if n8n API is configured
if (isN8nApiConfigured()) {
tools.push(...n8nManagementTools);
}
response = {
jsonrpc: '2.0',
result: {
tools
},
id: jsonRpcRequest.id
};
break;
case 'tools/call':
// Delegate to the MCP server
const toolName = jsonRpcRequest.params?.name;
const toolArgs = jsonRpcRequest.params?.arguments || {};
try {
const result = await mcpServer.executeTool(toolName, toolArgs);
// Convert result to JSON text for content field
let responseText = JSON.stringify(result, null, 2);
// Build MCP-compliant response with structuredContent for validation tools
const mcpResult: MCPToolResponse = {
content: [
{
type: 'text',
text: responseText
}
]
};
// Add structuredContent for validation tools (they have outputSchema)
// Apply 1MB safety limit to prevent memory issues (matches STDIO server behavior)
if (toolName.startsWith('validate_')) {
const resultSize = responseText.length;
if (resultSize > 1000000) {
// Response is too large - truncate and warn
logger.warn(
`Validation tool ${toolName} response is very large (${resultSize} chars). ` +
`Truncating for HTTP transport safety.`
);
mcpResult.content[0].text = responseText.substring(0, 999000) +
'\n\n[Response truncated due to size limits]';
// Don't include structuredContent for truncated responses
} else {
// Normal case - include structured content for MCP protocol compliance
mcpResult.structuredContent = result;
}
}
response = {
jsonrpc: '2.0',
result: mcpResult,
id: jsonRpcRequest.id
};
} catch (error) {
response = {
jsonrpc: '2.0',
error: {
code: -32603,
message: `Error executing tool ${toolName}: ${error instanceof Error ? error.message : 'Unknown error'}`
},
id: jsonRpcRequest.id
};
}
break;
default:
response = {
jsonrpc: '2.0',
error: {
code: -32601,
message: `Method not found: ${jsonRpcRequest.method}`
},
id: jsonRpcRequest.id
};
}
// Send response
res.setHeader('Content-Type', 'application/json');
res.json(response);
const duration = Date.now() - startTime;
logger.info('MCP request completed', {
duration,
method: jsonRpcRequest.method
});
} catch (error) {
logger.error('Error processing request:', error);
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32700,
message: 'Parse error',
data: error instanceof Error ? error.message : 'Unknown error'
},
id: null
});
}
});
} catch (error) {
logger.error('MCP request error:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
data: process.env.NODE_ENV === 'development'
? (error as Error).message
: undefined
},
id: null
});
}
}
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Not found',
message: `Cannot ${req.method} ${req.path}`
});
});
// Error handler
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Express error handler:', err);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
data: process.env.NODE_ENV === 'development' ? err.message : undefined
},
id: null
});
}
});
const port = parseInt(process.env.PORT || '3000');
const host = process.env.HOST || '0.0.0.0';
expressServer = app.listen(port, host, () => {
logger.info(`n8n MCP Fixed HTTP Server started`, { port, host });
// Detect the base URL using our utility
const baseUrl = getStartupBaseUrl(host, port);
const endpoints = formatEndpointUrls(baseUrl);
console.log(`n8n MCP Fixed HTTP Server running on ${host}:${port}`);
console.log(`Health check: ${endpoints.health}`);
console.log(`MCP endpoint: ${endpoints.mcp}`);
console.log('\nPress Ctrl+C to stop the server');
// Start periodic warning timer if using default token
if (authToken === 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh') {
setInterval(() => {
logger.warn('โ ๏ธ Still using default AUTH_TOKEN - security risk!');
if (process.env.MCP_MODE === 'http') {
console.warn('โ ๏ธ REMINDER: Still using default AUTH_TOKEN - please change it!');
}
}, 300000); // Every 5 minutes
}
if (process.env.BASE_URL || process.env.PUBLIC_URL) {
console.log(`\nPublic URL configured: ${baseUrl}`);
} else if (process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) {
console.log(`\nNote: TRUST_PROXY is enabled. URLs will be auto-detected from proxy headers.`);
}
});
// Handle errors
expressServer.on('error', (error: any) => {
if (error.code === 'EADDRINUSE') {
logger.error(`Port ${port} is already in use`);
console.error(`ERROR: Port ${port} is already in use`);
process.exit(1);
} else {
logger.error('Server error:', error);
console.error('Server error:', error);
process.exit(1);
}
});
// Graceful shutdown handlers
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
// Handle uncaught errors
process.on('uncaughtException', (error) => {
logger.error('Uncaught exception:', error);
console.error('Uncaught exception:', error);
shutdown();
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection:', reason);
console.error('Unhandled rejection at:', promise, 'reason:', reason);
shutdown();
});
}
// Make executeTool public on the server
declare module './mcp/server' {
interface N8NDocumentationMCPServer {
executeTool(name: string, args: any): Promise<any>;
}
}
// Start if called directly
// Check if this file is being run directly (not imported)
// In ES modules, we check import.meta.url against process.argv[1]
// But since we're transpiling to CommonJS, we use the require.main check
if (typeof require !== 'undefined' && require.main === module) {
startFixedHTTPServer().catch(error => {
logger.error('Failed to start Fixed HTTP server:', error);
console.error('Failed to start Fixed HTTP server:', error);
process.exit(1);
});
}