From 00a6f700ed7876ac8d80db33654c28f6981a3087 Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Fri, 13 Jun 2025 12:12:04 +0200 Subject: [PATCH] feat: enhance HTTP server with security, CORS, testing and error handling --- .env.example | 7 +- CLAUDE.md | 16 ++-- README.md | 16 ++-- docs/HTTP_DEPLOYMENT.md | 71 +++++++++++++- package.json | 2 +- scripts/test-http.sh | 132 +++++++++++++++++++++++++ src/http-server.ts | 208 +++++++++++++++++++++++++++++++++++----- 7 files changed, 411 insertions(+), 41 deletions(-) create mode 100755 scripts/test-http.sh diff --git a/.env.example b/.env.example index 857a5cb..cf5a7de 100644 --- a/.env.example +++ b/.env.example @@ -42,4 +42,9 @@ HOST=0.0.0.0 # Authentication token for HTTP mode (REQUIRED) # Generate with: openssl rand -base64 32 -AUTH_TOKEN=your-secure-token-here \ No newline at end of file +AUTH_TOKEN=your-secure-token-here + +# CORS origin for HTTP mode (optional) +# Default: * (allow all origins) +# For production, set to your specific domain +# CORS_ORIGIN=https://your-client-domain.com \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 0307b9f..c32122d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -260,13 +260,17 @@ npm run start:http # Client setup (Claude Desktop config) { "mcpServers": { - "n8n-documentation": { - "command": "mcp-remote", + "n8n-remote": { + "command": "npx", "args": [ - "https://your-server.com/mcp", - "--header", - "Authorization: Bearer your-auth-token" - ] + "-y", + "@modelcontextprotocol/mcp-remote@latest", + "connect", + "https://your-server.com/mcp" + ], + "env": { + "MCP_AUTH_TOKEN": "your-auth-token" + } } } } diff --git a/README.md b/README.md index ec9640a..658e5e0 100644 --- a/README.md +++ b/README.md @@ -210,13 +210,17 @@ npm run start:http ```json { "mcpServers": { - "n8n-documentation": { - "command": "mcp-remote", + "n8n-remote": { + "command": "npx", "args": [ - "https://your-server.com/mcp", - "--header", - "Authorization: Bearer your-auth-token" - ] + "-y", + "@modelcontextprotocol/mcp-remote@latest", + "connect", + "https://your-server.com/mcp" + ], + "env": { + "MCP_AUTH_TOKEN": "your-auth-token" + } } } } diff --git a/docs/HTTP_DEPLOYMENT.md b/docs/HTTP_DEPLOYMENT.md index 6bb7200..9e64461 100644 --- a/docs/HTTP_DEPLOYMENT.md +++ b/docs/HTTP_DEPLOYMENT.md @@ -107,12 +107,52 @@ npm install -g mcp-remote Edit Claude Desktop config: +**Option 1: Using global mcp-remote installation** ```json { "mcpServers": { - "n8n-documentation": { + "n8n-remote": { "command": "mcp-remote", "args": [ + "connect", + "https://your-domain.com/mcp" + ], + "env": { + "MCP_AUTH_TOKEN": "your-secure-token-here" + } + } + } +} +``` + +**Option 2: Using npx (no installation required)** +```json +{ + "mcpServers": { + "n8n-remote": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/mcp-remote@latest", + "connect", + "https://your-domain.com/mcp" + ], + "env": { + "MCP_AUTH_TOKEN": "your-secure-token-here" + } + } + } +} +``` + +**Option 3: Using custom headers (if needed)** +```json +{ + "mcpServers": { + "n8n-remote": { + "command": "mcp-remote", + "args": [ + "connect", "https://your-domain.com/mcp", "--header", "Authorization: Bearer your-secure-token-here" @@ -152,21 +192,50 @@ Expected response: } ``` +## Testing + +Use the included test script to verify your HTTP server: + +```bash +# Test local server +./scripts/test-http.sh + +# Test remote server +./scripts/test-http.sh https://your-domain.com + +# Test with custom token +AUTH_TOKEN=your-token ./scripts/test-http.sh + +# Verbose output +VERBOSE=1 ./scripts/test-http.sh +``` + +The test script checks: +- Health endpoint +- CORS preflight +- Authentication +- Valid MCP requests +- Error handling +- Request size limits + ## Troubleshooting ### Connection Refused - Check firewall rules - Verify server is running - Check nginx/proxy configuration +- Run the test script to diagnose ### Authentication Failed - Verify AUTH_TOKEN matches in both server and client - Check Authorization header format +- Token should be at least 32 characters ### MCP Tools Not Available - Restart Claude Desktop - Check mcp-remote installation - Verify server logs for errors +- Ensure CORS headers are working ## Performance Tips diff --git a/package.json b/package.json index f7e75bf..9dee8c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "1.0.0", + "version": "2.3.0", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "scripts": { diff --git a/scripts/test-http.sh b/scripts/test-http.sh new file mode 100755 index 0000000..a8dbb62 --- /dev/null +++ b/scripts/test-http.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Test script for n8n-MCP HTTP Server + +set -e + +# Configuration +URL="${1:-http://localhost:3000}" +TOKEN="${AUTH_TOKEN:-test-token}" +VERBOSE="${VERBOSE:-0}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "๐Ÿงช Testing n8n-MCP HTTP Server" +echo "================================" +echo "Server URL: $URL" +echo "" + +# Check if jq is installed +if ! command -v jq &> /dev/null; then + echo -e "${YELLOW}Warning: jq not installed. Output will not be formatted.${NC}" + echo "Install with: brew install jq (macOS) or apt-get install jq (Linux)" + echo "" + JQ="cat" +else + JQ="jq ." +fi + +# Function to make requests +make_request() { + local method="$1" + local endpoint="$2" + local data="$3" + local headers="$4" + local expected_status="$5" + + if [ "$VERBOSE" = "1" ]; then + echo -e "${YELLOW}Request:${NC} $method $URL$endpoint" + [ -n "$data" ] && echo -e "${YELLOW}Data:${NC} $data" + fi + + # Build curl command + local cmd="curl -s -w '\n%{http_code}' -X $method '$URL$endpoint'" + [ -n "$headers" ] && cmd="$cmd $headers" + [ -n "$data" ] && cmd="$cmd -d '$data'" + + # Execute and capture response + local response=$(eval "$cmd") + local body=$(echo "$response" | sed '$d') + local status=$(echo "$response" | tail -n 1) + + # Check status + if [ "$status" = "$expected_status" ]; then + echo -e "${GREEN}โœ“${NC} $method $endpoint - Status: $status" + else + echo -e "${RED}โœ—${NC} $method $endpoint - Expected: $expected_status, Got: $status" + fi + + # Show response body + if [ -n "$body" ]; then + echo "$body" | $JQ + fi + echo "" +} + +# Test 1: Health check +echo "1. Testing health endpoint..." +make_request "GET" "/health" "" "" "200" + +# Test 2: OPTIONS request (CORS preflight) +echo "2. Testing CORS preflight..." +make_request "OPTIONS" "/mcp" "" "-H 'Origin: http://localhost' -H 'Access-Control-Request-Method: POST'" "204" + +# Test 3: Authentication failure +echo "3. Testing authentication (should fail)..." +make_request "POST" "/mcp" \ + '{"jsonrpc":"2.0","method":"tools/list","id":1}' \ + "-H 'Content-Type: application/json' -H 'Authorization: Bearer wrong-token'" \ + "401" + +# Test 4: Missing authentication +echo "4. Testing missing authentication..." +make_request "POST" "/mcp" \ + '{"jsonrpc":"2.0","method":"tools/list","id":1}' \ + "-H 'Content-Type: application/json'" \ + "401" + +# Test 5: Valid MCP request to list tools +echo "5. Testing valid MCP request (list tools)..." +make_request "POST" "/mcp" \ + '{"jsonrpc":"2.0","method":"tools/list","id":1}' \ + "-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN' -H 'Accept: text/event-stream'" \ + "200" + +# Test 6: 404 for unknown endpoint +echo "6. Testing 404 response..." +make_request "GET" "/unknown" "" "" "404" + +# Test 7: Invalid JSON +echo "7. Testing invalid JSON..." +make_request "POST" "/mcp" \ + '{invalid json}' \ + "-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN'" \ + "400" + +# Test 8: Request size limit +echo "8. Testing request size limit..." +# Generate a large payload (> 1MB) +LARGE_DATA=$(printf '{"jsonrpc":"2.0","method":"test","params":{"data":"%*s"},"id":1}' 1100000 | tr ' ' 'x') +make_request "POST" "/mcp" \ + "$LARGE_DATA" \ + "-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN'" \ + "413" + +# Test 9: MCP initialization +if [ "$VERBOSE" = "1" ]; then + echo "9. Testing MCP initialization..." + make_request "POST" "/mcp" \ + '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{"roots":{}}},"id":1}' \ + "-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN' -H 'Accept: text/event-stream'" \ + "200" +fi + +echo "================================" +echo "๐ŸŽ‰ Tests completed!" +echo "" +echo "To run with verbose output: VERBOSE=1 $0" +echo "To test a different server: $0 https://your-server.com" +echo "To use a different token: AUTH_TOKEN=your-token $0" \ No newline at end of file diff --git a/src/http-server.ts b/src/http-server.ts index 8acfd95..574fa22 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -11,48 +11,138 @@ import dotenv from 'dotenv'; dotenv.config(); -export async function startHTTPServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); +let server: any; + +/** + * Validate required environment variables + */ +function validateEnvironment() { + const required = ['AUTH_TOKEN']; + const missing = required.filter(key => !process.env[key]); - // Simple token auth - const authToken = process.env.AUTH_TOKEN; - if (!authToken) { - logger.error('AUTH_TOKEN environment variable required'); - console.error('ERROR: AUTH_TOKEN environment variable is required for HTTP mode'); - console.error('Generate one with: openssl rand -base64 32'); + if (missing.length > 0) { + logger.error(`Missing required environment variables: ${missing.join(', ')}`); + console.error(`ERROR: Missing required environment variables: ${missing.join(', ')}`); + console.error('Generate AUTH_TOKEN with: openssl rand -base64 32'); process.exit(1); } + // Validate AUTH_TOKEN length + if (process.env.AUTH_TOKEN && process.env.AUTH_TOKEN.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'); + } +} + +/** + * Graceful shutdown handler + */ +async function shutdown() { + logger.info('Shutting down HTTP server...'); + console.log('Shutting down HTTP server...'); + + if (server) { + server.close(() => { + logger.info('HTTP server closed'); + console.log('HTTP server closed'); + process.exit(0); + }); + + // Force shutdown after 10 seconds + setTimeout(() => { + logger.error('Forced shutdown after timeout'); + process.exit(1); + }, 10000); + } else { + process.exit(0); + } +} + +export async function startHTTPServer() { + // Validate environment + validateEnvironment(); + + const app = express(); + + // Parse JSON with strict limits + app.use(express.json({ + limit: '1mb', // More reasonable than 10mb + strict: true // Only accept arrays and objects + })); + + // 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 for mcp-remote compatibility + 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'); // 24 hours + + if (req.method === 'OPTIONS') { + res.sendStatus(204); + return; + } + next(); + }); + // Request logging middleware app.use((req, res, next) => { logger.info(`${req.method} ${req.path}`, { ip: req.ip, - userAgent: req.get('user-agent') + userAgent: req.get('user-agent'), + contentLength: req.get('content-length') }); next(); }); - // Health check endpoint + // Enhanced health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', mode: 'http', - version: '2.3.0' + version: '2.3.0', + 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() }); }); // Main MCP endpoint - Create a new server and transport for each request (stateless) app.post('/mcp', async (req: express.Request, res: express.Response): Promise => { + const startTime = Date.now(); + // Simple auth check const authHeader = req.headers.authorization; const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : authHeader; - if (token !== authToken) { - logger.warn('Authentication failed', { ip: req.ip }); - res.status(401).json({ error: 'Unauthorized' }); + if (token !== process.env.AUTH_TOKEN) { + logger.warn('Authentication failed', { + ip: req.ip, + userAgent: req.get('user-agent') + }); + res.status(401).json({ + jsonrpc: '2.0', + error: { + code: -32001, + message: 'Unauthorized' + }, + id: null + }); return; } @@ -68,42 +158,108 @@ export async function startHTTPServer() { // Connect server to transport await mcpServer.connect(transport); - // Handle the request - await transport.handleRequest(req, res, req.body); + // Handle the request - Fixed: removed third parameter + await transport.handleRequest(req, res); + + // Log request duration + const duration = Date.now() - startTime; + logger.info('MCP request completed', { + duration, + method: req.body?.method + }); // Clean up on close res.on('close', () => { logger.debug('Request closed, cleaning up'); - transport.close(); + transport.close().catch(err => + logger.error('Error closing transport:', err) + ); }); } catch (error) { logger.error('MCP request error:', error); + const duration = Date.now() - startTime; + logger.error('MCP request failed', { duration }); + if (!res.headersSent) { res.status(500).json({ - error: 'Internal server error', - message: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + 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('Request error:', err); - res.status(500).json({ - error: 'Internal server error', - message: process.env.NODE_ENV === 'development' ? err.message : undefined - }); + 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'; - app.listen(port, host, () => { + server = app.listen(port, host, () => { logger.info(`n8n MCP HTTP Server started`, { port, host }); console.log(`n8n MCP HTTP Server running on ${host}:${port}`); console.log(`Health check: http://localhost:${port}/health`); console.log(`MCP endpoint: http://localhost:${port}/mcp`); + console.log('\nPress Ctrl+C to stop the server'); + }); + + // Handle errors + server.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(); }); }