feat: enhance HTTP server with security, CORS, testing and error handling
This commit is contained in:
@@ -43,3 +43,8 @@ HOST=0.0.0.0
|
||||
# Authentication token for HTTP mode (REQUIRED)
|
||||
# Generate with: openssl rand -base64 32
|
||||
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
|
||||
16
CLAUDE.md
16
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
README.md
16
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
132
scripts/test-http.sh
Executable file
132
scripts/test-http.sh
Executable file
@@ -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"
|
||||
@@ -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;
|
||||
|
||||
// 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');
|
||||
/**
|
||||
* Validate required environment variables
|
||||
*/
|
||||
function validateEnvironment() {
|
||||
const required = ['AUTH_TOKEN'];
|
||||
const missing = required.filter(key => !process.env[key]);
|
||||
|
||||
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<void> => {
|
||||
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);
|
||||
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 : undefined
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user