From ee8aa729c11895be6d7657a28e265edad4e88592 Mon Sep 17 00:00:00 2001 From: czlonkowski Date: Sun, 8 Jun 2025 07:31:12 +0000 Subject: [PATCH] Implement remote MCP server deployment capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add HTTP/JSON-RPC server for remote MCP access - Configure domain and authentication via environment variables - Create comprehensive remote deployment documentation - Support both local (stdio) and remote (HTTP) deployment modes - Add PM2 and Nginx configuration examples - Update README with remote server instructions The server can now be deployed on a VM (e.g., Hetzner) and accessed from Claude Desktop over HTTPS using the configured domain. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 42 ++- README.md | 54 +++- docs/REMOTE_DEPLOYMENT.md | 432 +++++++++++++++++++++++++ package-lock.json | 11 + package.json | 3 + src/index-http.ts | 93 ++++++ src/mcp/http-server.ts | 592 ++++++++++++++++++++++++++++++++++ src/mcp/remote-server.ts | 608 +++++++++++++++++++++++++++++++++++ src/utils/auth-middleware.ts | 49 +++ 9 files changed, 1867 insertions(+), 17 deletions(-) create mode 100644 docs/REMOTE_DEPLOYMENT.md create mode 100644 src/index-http.ts create mode 100644 src/mcp/http-server.ts create mode 100644 src/mcp/remote-server.ts create mode 100644 src/utils/auth-middleware.ts diff --git a/.env.example b/.env.example index e6c164d..37f4b05 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,32 @@ -# n8n Configuration -N8N_BASIC_AUTH_USER=admin -N8N_BASIC_AUTH_PASSWORD=your-secure-password-here -N8N_HOST=localhost -N8N_API_KEY=your-api-key-here +# n8n Documentation MCP Remote Server Configuration -# MCP Configuration -MCP_LOG_LEVEL=info -NODE_ENV=production +# Remote Server Configuration +MCP_PORT=3000 +MCP_HOST=0.0.0.0 +MCP_DOMAIN=n8ndocumentation.aiservices.pl + +# Authentication (REQUIRED for production) +# Generate a secure token: openssl rand -hex 32 +MCP_AUTH_TOKEN=your-secure-auth-token-here + +# CORS - Enable for browser-based access +MCP_CORS=true + +# TLS Configuration (optional but recommended for production) +# MCP_TLS_CERT=/path/to/cert.pem +# MCP_TLS_KEY=/path/to/key.pem # Database Configuration -NODE_DB_PATH=/app/data/nodes.db +NODE_DB_PATH=/app/data/nodes-v2.db -# Optional: External n8n instance -# N8N_API_URL=http://your-n8n-instance:5678 +# Node.js Environment +NODE_ENV=production -# MCP Server Configuration (if using HTTP transport) -# MCP_SERVER_PORT=3000 -# MCP_SERVER_HOST=localhost +# Logging +MCP_LOG_LEVEL=info -# Authentication -# MCP_AUTH_TOKEN=your-secure-token \ No newline at end of file +# Legacy n8n Configuration (not used in v2) +# N8N_BASIC_AUTH_USER=admin +# N8N_BASIC_AUTH_PASSWORD=your-secure-password-here +# N8N_HOST=localhost +# N8N_API_KEY=your-api-key-here \ No newline at end of file diff --git a/README.md b/README.md index e1d32ac..f4454e3 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,15 @@ npm run build npm run db:rebuild:v2 ``` +### Deployment Options + +This MCP server can be deployed in two ways: + +1. **Local Installation** - Run on your machine and connect Claude Desktop locally +2. **Remote Deployment** - Deploy to a VM/server and connect Claude Desktop over HTTPS + +For remote deployment instructions, see [docs/REMOTE_DEPLOYMENT.md](docs/REMOTE_DEPLOYMENT.md). + ## Installing in Claude Desktop ### 1. Build the project first @@ -90,6 +99,30 @@ In Claude, you should see "n8n-nodes" in the MCP connections. Try asking: - "Search for webhook nodes in n8n" - "Show me the source code for the HTTP Request node" +### Remote Server Configuration + +If you're connecting to a remote server instead of local installation: + +```json +{ + "mcpServers": { + "n8n-nodes-remote": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/client-http", + "https://n8ndocumentation.aiservices.pl/mcp" + ], + "env": { + "MCP_AUTH_TOKEN": "your-auth-token-from-server" + } + } + } +} +``` + +Replace `n8ndocumentation.aiservices.pl` with your actual domain and use the auth token configured on your server. + ## Available MCP Tools ### `list_nodes` @@ -193,9 +226,12 @@ The SQLite database is stored at: `data/nodes-v2.db` ## Development ```bash -# Run in development mode +# Run in development mode (local stdio) npm run dev:v2 +# Run HTTP server for remote access +npm run dev:http + # Run tests npm run test:v2 @@ -203,6 +239,22 @@ npm run test:v2 npm run typecheck ``` +### Running Remote Server + +To run the server for remote access: + +```bash +# Copy and configure environment +cp .env.example .env +# Edit .env with your domain and auth token + +# Run in production mode +npm run start:http + +# Or with PM2 for production +pm2 start dist/index-http.js --name n8n-mcp +``` + ## Troubleshooting ### Claude Desktop doesn't show the MCP server diff --git a/docs/REMOTE_DEPLOYMENT.md b/docs/REMOTE_DEPLOYMENT.md new file mode 100644 index 0000000..e4e2925 --- /dev/null +++ b/docs/REMOTE_DEPLOYMENT.md @@ -0,0 +1,432 @@ +# Remote Deployment Guide + +This guide explains how to deploy the n8n Documentation MCP Server to a remote VM (such as Hetzner) and connect to it from Claude Desktop. + +## Overview + +The n8n Documentation MCP Server can be deployed as a remote HTTP service, allowing Claude Desktop to access n8n node documentation over the internet. This is useful for: + +- Centralized documentation serving for teams +- Accessing documentation without local n8n installation +- Cloud-based AI development workflows + +## Architecture + +``` +Claude Desktop → Internet → MCP Server (HTTPS) → SQLite Database + ↓ + n8n Documentation +``` + +## Prerequisites + +- A VM with Ubuntu 20.04+ or similar Linux distribution +- Node.js 18+ installed +- A domain name (e.g., `n8ndocumentation.aiservices.pl`) +- SSL certificate (Let's Encrypt recommended) +- Basic knowledge of Linux server administration + +## Deployment Steps + +### 1. Server Setup + +SSH into your VM and prepare the environment: + +```bash +# Update system packages +sudo apt update && sudo apt upgrade -y + +# Install Node.js 18+ (if not already installed) +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt install -y nodejs + +# Install git and build essentials +sudo apt install -y git build-essential + +# Install PM2 for process management +sudo npm install -g pm2 + +# Create application directory +sudo mkdir -p /opt/n8n-mcp +sudo chown $USER:$USER /opt/n8n-mcp +``` + +### 2. Clone and Build + +```bash +cd /opt +git clone https://github.com/yourusername/n8n-mcp.git +cd n8n-mcp + +# Install dependencies +npm install + +# Build the project +npm run build + +# Initialize database +npm run db:rebuild:v2 +``` + +### 3. Configure Environment + +Create the production environment file: + +```bash +cp .env.example .env +nano .env +``` + +Configure with your domain and security settings: + +```env +# Remote Server Configuration +MCP_PORT=3000 +MCP_HOST=0.0.0.0 +MCP_DOMAIN=n8ndocumentation.aiservices.pl + +# Authentication - REQUIRED for production +# Generate secure token: openssl rand -hex 32 +MCP_AUTH_TOKEN=your-generated-secure-token-here + +# Enable CORS for browser access +MCP_CORS=true + +# Database path +NODE_DB_PATH=/opt/n8n-mcp/data/nodes-v2.db + +# Production environment +NODE_ENV=production +MCP_LOG_LEVEL=info +``` + +### 4. Setup SSL with Nginx + +Install and configure Nginx as a reverse proxy with SSL: + +```bash +# Install Nginx and Certbot +sudo apt install -y nginx certbot python3-certbot-nginx + +# Create Nginx configuration +sudo nano /etc/nginx/sites-available/n8n-mcp +``` + +Add the following configuration: + +```nginx +server { + listen 80; + server_name n8ndocumentation.aiservices.pl; + + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + listen 443 ssl; + server_name n8ndocumentation.aiservices.pl; + + # SSL will be configured by Certbot + + # Security headers + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; + add_header X-XSS-Protection "1; mode=block"; + + # Proxy settings + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # Increase timeouts for MCP operations + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} +``` + +Enable the site and obtain SSL certificate: + +```bash +# Enable the site +sudo ln -s /etc/nginx/sites-available/n8n-mcp /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx + +# Obtain SSL certificate +sudo certbot --nginx -d n8ndocumentation.aiservices.pl +``` + +### 5. Start with PM2 + +Create PM2 ecosystem file: + +```bash +nano /opt/n8n-mcp/ecosystem.config.js +``` + +```javascript +module.exports = { + apps: [{ + name: 'n8n-mcp', + script: './dist/index-http.js', + cwd: '/opt/n8n-mcp', + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '1G', + env: { + NODE_ENV: 'production' + }, + error_file: '/opt/n8n-mcp/logs/error.log', + out_file: '/opt/n8n-mcp/logs/out.log', + log_file: '/opt/n8n-mcp/logs/combined.log', + time: true + }] +}; +``` + +Start the application: + +```bash +# Create logs directory +mkdir -p /opt/n8n-mcp/logs + +# Start with PM2 +pm2 start ecosystem.config.js + +# Save PM2 configuration +pm2 save + +# Setup PM2 to start on boot +pm2 startup +``` + +### 6. Configure Firewall + +```bash +# Allow SSH, HTTP, and HTTPS +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +## Claude Desktop Configuration + +### 1. Get your auth token + +From your server, get the configured auth token: + +```bash +grep MCP_AUTH_TOKEN /opt/n8n-mcp/.env +``` + +### 2. Configure Claude Desktop + +Edit your Claude Desktop configuration: + +**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +**Linux**: `~/.config/Claude/claude_desktop_config.json` + +Add the remote MCP server: + +```json +{ + "mcpServers": { + "n8n-nodes-remote": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/client-http", + "https://n8ndocumentation.aiservices.pl/mcp" + ], + "env": { + "MCP_AUTH_TOKEN": "your-auth-token-here" + } + } + } +} +``` + +### 3. Restart Claude Desktop + +Quit and restart Claude Desktop to load the new configuration. + +## Server Management + +### Viewing Logs + +```bash +# View real-time logs +pm2 logs n8n-mcp + +# View error logs +tail -f /opt/n8n-mcp/logs/error.log + +# View Nginx logs +sudo tail -f /var/log/nginx/access.log +sudo tail -f /var/log/nginx/error.log +``` + +### Rebuilding Database + +To update the node documentation database: + +```bash +cd /opt/n8n-mcp + +# Stop the server +pm2 stop n8n-mcp + +# Rebuild database +npm run db:rebuild:v2 + +# Restart server +pm2 restart n8n-mcp +``` + +### Updating the Server + +```bash +cd /opt/n8n-mcp + +# Pull latest changes +git pull + +# Install dependencies +npm install + +# Build +npm run build + +# Restart +pm2 restart n8n-mcp +``` + +## Security Considerations + +1. **Authentication Token**: Always use a strong, randomly generated token + ```bash + openssl rand -hex 32 + ``` + +2. **HTTPS**: Always use HTTPS in production. The setup above includes automatic SSL with Let's Encrypt. + +3. **Firewall**: Only open necessary ports (22, 80, 443) + +4. **Updates**: Keep the system and Node.js updated regularly + +5. **Monitoring**: Set up monitoring for the service: + ```bash + # PM2 monitoring + pm2 install pm2-logrotate + pm2 set pm2-logrotate:max_size 10M + pm2 set pm2-logrotate:retain 7 + ``` + +## API Endpoints + +Once deployed, your server provides: + +- `GET https://n8ndocumentation.aiservices.pl/` - Server information +- `GET https://n8ndocumentation.aiservices.pl/health` - Health check +- `GET https://n8ndocumentation.aiservices.pl/stats` - Database statistics +- `POST https://n8ndocumentation.aiservices.pl/mcp` - MCP protocol endpoint +- `POST https://n8ndocumentation.aiservices.pl/rebuild` - Rebuild database (requires auth) + +## Troubleshooting + +### Connection Issues + +1. Check if the server is running: + ```bash + pm2 status + curl https://n8ndocumentation.aiservices.pl/health + ``` + +2. Verify Nginx is working: + ```bash + sudo nginx -t + sudo systemctl status nginx + ``` + +3. Check firewall: + ```bash + sudo ufw status + ``` + +### Authentication Failures + +1. Verify the token matches in both `.env` and Claude config +2. Check server logs for auth errors: + ```bash + pm2 logs n8n-mcp --lines 100 + ``` + +### Database Issues + +1. Check database exists: + ```bash + ls -la /opt/n8n-mcp/data/nodes-v2.db + ``` + +2. Rebuild if necessary: + ```bash + cd /opt/n8n-mcp + npm run db:rebuild:v2 + ``` + +## Monitoring and Maintenance + +### Health Monitoring + +Set up external monitoring (e.g., UptimeRobot) to check: +- `https://n8ndocumentation.aiservices.pl/health` + +### Backup + +Regular backups of the database: + +```bash +# Create backup script +cat > /opt/n8n-mcp/backup.sh << 'EOF' +#!/bin/bash +BACKUP_DIR="/opt/n8n-mcp/backups" +mkdir -p $BACKUP_DIR +cp /opt/n8n-mcp/data/nodes-v2.db "$BACKUP_DIR/nodes-v2-$(date +%Y%m%d-%H%M%S).db" +# Keep only last 7 backups +find $BACKUP_DIR -name "nodes-v2-*.db" -mtime +7 -delete +EOF + +chmod +x /opt/n8n-mcp/backup.sh + +# Add to crontab (daily at 2 AM) +(crontab -l 2>/dev/null; echo "0 2 * * * /opt/n8n-mcp/backup.sh") | crontab - +``` + +## Cost Optimization + +For a small Hetzner VM (CX11 - 1 vCPU, 2GB RAM): +- Monthly cost: ~€4-5 +- Sufficient for serving documentation to multiple Claude instances +- Can handle hundreds of concurrent connections + +## Support + +For issues specific to remote deployment: +1. Check server logs first +2. Verify network connectivity +3. Ensure all dependencies are installed +4. Check GitHub issues for similar problems \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8532236..c0f2536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@types/express": "^5.0.3", "@types/jest": "^29.5.14", "@types/node": "^22.15.30", + "@types/ws": "^8.18.1", "jest": "^29.7.0", "nodemon": "^3.1.10", "ts-jest": "^29.3.4", @@ -4319,6 +4320,16 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", diff --git a/package.json b/package.json index 73b8015..df4a996 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "build": "tsc", "dev": "nodemon --exec ts-node src/index.ts", "dev:v2": "nodemon --exec ts-node src/index-v2.ts", + "dev:http": "nodemon --exec ts-node src/index-http.ts", "start": "node dist/index.js", "start:v2": "node dist/index-v2.js", + "start:http": "node dist/index-http.js", "test": "jest", "lint": "tsc --noEmit", "typecheck": "tsc --noEmit", @@ -32,6 +34,7 @@ "@types/express": "^5.0.3", "@types/jest": "^29.5.14", "@types/node": "^22.15.30", + "@types/ws": "^8.18.1", "jest": "^29.7.0", "nodemon": "^3.1.10", "ts-jest": "^29.3.4", diff --git a/src/index-http.ts b/src/index-http.ts new file mode 100644 index 0000000..8718be1 --- /dev/null +++ b/src/index-http.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import dotenv from 'dotenv'; +import { N8NDocumentationRemoteServer } from './mcp/remote-server'; +import { logger } from './utils/logger'; +import * as path from 'path'; + +// Load environment variables +dotenv.config(); + +async function main() { + try { + // Get configuration from environment + const config = { + port: parseInt(process.env.MCP_PORT || '3000', 10), + host: process.env.MCP_HOST || '0.0.0.0', + domain: process.env.MCP_DOMAIN || 'localhost', + authToken: process.env.MCP_AUTH_TOKEN, + cors: process.env.MCP_CORS === 'true', + tlsCert: process.env.MCP_TLS_CERT, + tlsKey: process.env.MCP_TLS_KEY, + }; + + // Validate required configuration + if (!config.domain || config.domain === 'localhost') { + logger.warn('MCP_DOMAIN not set or set to localhost. Using default: localhost'); + logger.warn('For production, set MCP_DOMAIN to your actual domain (e.g., n8ndocumentation.aiservices.pl)'); + } + + if (!config.authToken) { + logger.warn('MCP_AUTH_TOKEN not set. Server will run without authentication.'); + logger.warn('For production, set MCP_AUTH_TOKEN to a secure value.'); + } + + // Set database path if not already set + if (!process.env.NODE_DB_PATH) { + process.env.NODE_DB_PATH = path.join(__dirname, '../data/nodes-v2.db'); + } + + logger.info('Starting n8n Documentation MCP Remote Server'); + logger.info('Configuration:', { + port: config.port, + host: config.host, + domain: config.domain, + cors: config.cors, + authEnabled: !!config.authToken, + tlsEnabled: !!(config.tlsCert && config.tlsKey), + databasePath: process.env.NODE_DB_PATH, + }); + + const server = new N8NDocumentationRemoteServer(config); + + // Start the server + await server.start(); + + // Handle graceful shutdown + const shutdown = async () => { + logger.info('Received shutdown signal'); + await server.stop(); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + logger.info('Server is ready to accept connections'); + logger.info(`Claude Desktop configuration:`); + logger.info(JSON.stringify({ + "mcpServers": { + "n8n-nodes-remote": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", `Authorization: Bearer ${config.authToken || 'YOUR_AUTH_TOKEN'}`, + "-d", "@-", + `https://${config.domain}/mcp` + ], + "env": {} + } + } + }, null, 2)); + + } catch (error) { + logger.error('Failed to start server:', error); + process.exit(1); + } +} + +// Run the server +main().catch((error) => { + logger.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts new file mode 100644 index 0000000..870af64 --- /dev/null +++ b/src/mcp/http-server.ts @@ -0,0 +1,592 @@ +import express from 'express'; +import { createServer } from 'http'; +import { WebSocketServer, WebSocket } from 'ws'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +// WebSocketServerTransport is not available in the SDK, we'll implement a custom solution +import { + CallToolRequestSchema, + ErrorCode, + ListResourcesRequestSchema, + ListToolsRequestSchema, + McpError, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { NodeDocumentationService } from '../services/node-documentation-service'; +import { nodeDocumentationTools } from './tools-v2'; +import { logger } from '../utils/logger'; +import { authenticateRequest } from '../utils/auth-middleware'; +import * as crypto from 'crypto'; + +interface HttpServerConfig { + port: number; + host: string; + domain: string; + authToken?: string; + cors?: boolean; + tlsCert?: string; + tlsKey?: string; +} + +/** + * HTTP/WebSocket MCP Server for remote access + */ +export class N8NDocumentationHttpServer { + private app: express.Application; + private server: any; + private wss!: WebSocketServer; + private nodeService: NodeDocumentationService; + private config: HttpServerConfig; + private activeSessions: Map = new Map(); + + constructor(config: HttpServerConfig) { + this.config = config; + this.app = express(); + this.nodeService = new NodeDocumentationService(); + + this.setupMiddleware(); + this.setupRoutes(); + this.setupWebSocket(); + } + + private setupMiddleware(): void { + // JSON parsing + this.app.use(express.json()); + + // CORS if enabled + if (this.config.cors) { + this.app.use((req, res, next): void => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + if (req.method === 'OPTIONS') { + res.sendStatus(200); + return; + } + next(); + }); + } + + // Request logging + this.app.use((req, res, next): void => { + logger.info(`${req.method} ${req.path}`, { + ip: req.ip, + userAgent: req.get('user-agent') + }); + next(); + }); + } + + private setupRoutes(): void { + // Health check endpoint + this.app.get('/health', (req, res) => { + res.json({ + status: 'ok', + service: 'n8n-documentation-mcp', + version: '2.0.0', + uptime: process.uptime() + }); + }); + + // MCP info endpoint + this.app.get('/mcp', (req, res) => { + res.json({ + name: 'n8n-node-documentation', + version: '2.0.0', + description: 'MCP server providing n8n node documentation and source code', + transport: 'websocket', + endpoint: `wss://${this.config.domain}/mcp/websocket`, + authentication: 'bearer-token', + tools: nodeDocumentationTools.map(t => ({ + name: t.name, + description: t.description + })) + }); + }); + + // Database stats endpoint (public) + this.app.get('/stats', async (req, res) => { + try { + const stats = this.nodeService.getStatistics(); + res.json(stats); + } catch (error) { + logger.error('Failed to get statistics:', error); + res.status(500).json({ error: 'Failed to retrieve statistics' }); + } + }); + + // Rebuild endpoint (requires auth) + this.app.post('/rebuild', authenticateRequest(this.config.authToken), async (req, res) => { + try { + logger.info('Database rebuild requested'); + const stats = await this.nodeService.rebuildDatabase(); + res.json({ + message: 'Database rebuild complete', + stats + }); + } catch (error) { + logger.error('Rebuild failed:', error); + res.status(500).json({ error: 'Rebuild failed' }); + } + }); + } + + private setupWebSocket(): void { + // Create HTTP server + this.server = createServer(this.app); + + // Create WebSocket server + this.wss = new WebSocketServer({ + server: this.server, + path: '/mcp/websocket' + }); + + this.wss.on('connection', async (ws: WebSocket, req: any) => { + const sessionId = crypto.randomUUID(); + logger.info(`WebSocket connection established: ${sessionId}`); + + // Authenticate WebSocket connection + const authHeader = req.headers.authorization; + if (this.config.authToken && authHeader !== `Bearer ${this.config.authToken}`) { + logger.warn(`Unauthorized WebSocket connection attempt: ${sessionId}`); + ws.close(1008, 'Unauthorized'); + return; + } + + try { + // Create MCP server instance for this connection + const mcpServer = new Server( + { + name: 'n8n-node-documentation', + version: '2.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + // Setup MCP handlers + this.setupMcpHandlers(mcpServer); + + // WebSocket transport not available in SDK - implement JSON-RPC over WebSocket + // For now, we'll handle messages directly + ws.on('message', async (data: Buffer) => { + try { + const request = JSON.parse(data.toString()); + // Process request through MCP server handlers + // This would need custom implementation + logger.warn('WebSocket MCP not fully implemented yet'); + ws.send(JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + error: { + code: -32601, + message: 'WebSocket transport not implemented' + } + })); + } catch (error) { + logger.error('WebSocket message error:', error); + } + }); + + this.activeSessions.set(sessionId, { mcpServer, ws }); + logger.info(`MCP session established: ${sessionId}`); + + // Handle disconnect + ws.on('close', () => { + logger.info(`WebSocket connection closed: ${sessionId}`); + this.activeSessions.delete(sessionId); + }); + + } catch (error) { + logger.error(`Failed to establish MCP session: ${sessionId}`, error); + ws.close(1011, 'Server error'); + } + }); + } + + private setupMcpHandlers(server: Server): void { + // List available tools + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: nodeDocumentationTools, + })); + + // List available resources + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [ + { + uri: 'nodes://list', + name: 'Available n8n Nodes', + description: 'List of all available n8n nodes', + mimeType: 'application/json', + }, + { + uri: 'nodes://statistics', + name: 'Database Statistics', + description: 'Statistics about the node documentation database', + mimeType: 'application/json', + }, + ], + })); + + // Read resources + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + + try { + if (uri === 'nodes://list') { + const nodes = await this.nodeService.listNodes(); + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(nodes.map(n => ({ + nodeType: n.nodeType, + name: n.name, + displayName: n.displayName, + category: n.category, + description: n.description, + hasDocumentation: !!n.documentation, + hasExample: !!n.exampleWorkflow, + })), null, 2), + }, + ], + }; + } + + if (uri === 'nodes://statistics') { + const stats = this.nodeService.getStatistics(); + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(stats, null, 2), + }, + ], + }; + } + + throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${uri}`); + } catch (error) { + logger.error('Resource read error:', error); + throw error instanceof McpError ? error : new McpError( + ErrorCode.InternalError, + `Failed to read resource: ${error instanceof Error ? error.message : String(error)}` + ); + } + }); + + // Handle tool calls + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + switch (name) { + case 'list_nodes': + return await this.handleListNodes(args); + + case 'get_node_info': + return await this.handleGetNodeInfo(args); + + case 'search_nodes': + return await this.handleSearchNodes(args); + + case 'get_node_example': + return await this.handleGetNodeExample(args); + + case 'get_node_source_code': + return await this.handleGetNodeSourceCode(args); + + case 'get_node_documentation': + return await this.handleGetNodeDocumentation(args); + + case 'rebuild_database': + return await this.handleRebuildDatabase(args); + + case 'get_database_statistics': + return await this.handleGetStatistics(); + + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); + } + } catch (error) { + logger.error(`Tool execution error (${name}):`, error); + throw error instanceof McpError ? error : new McpError( + ErrorCode.InternalError, + `Tool execution failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + }); + } + + // Tool handlers (copied from server-v2.ts) + private async handleListNodes(args: any): Promise { + const nodes = await this.nodeService.listNodes(); + + let filtered = nodes; + + if (args.category) { + filtered = filtered.filter(n => n.category === args.category); + } + + if (args.packageName) { + filtered = filtered.filter(n => n.packageName === args.packageName); + } + + if (args.isTrigger !== undefined) { + filtered = filtered.filter(n => n.isTrigger === args.isTrigger); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filtered.map(n => ({ + nodeType: n.nodeType, + name: n.name, + displayName: n.displayName, + category: n.category, + description: n.description, + packageName: n.packageName, + hasDocumentation: !!n.documentation, + hasExample: !!n.exampleWorkflow, + isTrigger: n.isTrigger, + isWebhook: n.isWebhook, + })), null, 2), + }, + ], + }; + } + + private async handleGetNodeInfo(args: any): Promise { + if (!args.nodeType) { + throw new McpError(ErrorCode.InvalidParams, 'nodeType is required'); + } + + const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType); + + if (!nodeInfo) { + throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + nodeType: nodeInfo.nodeType, + name: nodeInfo.name, + displayName: nodeInfo.displayName, + description: nodeInfo.description, + category: nodeInfo.category, + packageName: nodeInfo.packageName, + sourceCode: nodeInfo.sourceCode, + credentialCode: nodeInfo.credentialCode, + documentation: nodeInfo.documentation, + documentationUrl: nodeInfo.documentationUrl, + exampleWorkflow: nodeInfo.exampleWorkflow, + exampleParameters: nodeInfo.exampleParameters, + propertiesSchema: nodeInfo.propertiesSchema, + isTrigger: nodeInfo.isTrigger, + isWebhook: nodeInfo.isWebhook, + }, null, 2), + }, + ], + }; + } + + private async handleSearchNodes(args: any): Promise { + if (!args.query) { + throw new McpError(ErrorCode.InvalidParams, 'query is required'); + } + + const results = await this.nodeService.searchNodes({ + query: args.query, + category: args.category, + limit: args.limit || 20, + }); + + let filtered = results; + if (args.hasDocumentation) { + filtered = filtered.filter(n => !!n.documentation); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filtered.map(n => ({ + nodeType: n.nodeType, + name: n.name, + displayName: n.displayName, + category: n.category, + description: n.description, + hasDocumentation: !!n.documentation, + hasExample: !!n.exampleWorkflow, + })), null, 2), + }, + ], + }; + } + + private async handleGetNodeExample(args: any): Promise { + if (!args.nodeType) { + throw new McpError(ErrorCode.InvalidParams, 'nodeType is required'); + } + + const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType); + + if (!nodeInfo) { + throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`); + } + + if (!nodeInfo.exampleWorkflow) { + return { + content: [ + { + type: 'text', + text: `No example available for node: ${args.nodeType}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(nodeInfo.exampleWorkflow, null, 2), + }, + ], + }; + } + + private async handleGetNodeSourceCode(args: any): Promise { + if (!args.nodeType) { + throw new McpError(ErrorCode.InvalidParams, 'nodeType is required'); + } + + const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType); + + if (!nodeInfo) { + throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`); + } + + const response: any = { + nodeType: nodeInfo.nodeType, + sourceCode: nodeInfo.sourceCode, + }; + + if (args.includeCredentials && nodeInfo.credentialCode) { + response.credentialCode = nodeInfo.credentialCode; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } + + private async handleGetNodeDocumentation(args: any): Promise { + if (!args.nodeType) { + throw new McpError(ErrorCode.InvalidParams, 'nodeType is required'); + } + + const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType); + + if (!nodeInfo) { + throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`); + } + + if (!nodeInfo.documentation) { + return { + content: [ + { + type: 'text', + text: `No documentation available for node: ${args.nodeType}`, + }, + ], + }; + } + + const content = args.format === 'plain' + ? nodeInfo.documentation.replace(/[#*`]/g, '') + : nodeInfo.documentation; + + return { + content: [ + { + type: 'text', + text: content, + }, + ], + }; + } + + private async handleRebuildDatabase(args: any): Promise { + logger.info('Database rebuild requested via MCP'); + + const stats = await this.nodeService.rebuildDatabase(); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Database rebuild complete', + stats, + }, null, 2), + }, + ], + }; + } + + private async handleGetStatistics(): Promise { + const stats = this.nodeService.getStatistics(); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(stats, null, 2), + }, + ], + }; + } + + async start(): Promise { + return new Promise((resolve) => { + this.server.listen(this.config.port, this.config.host, () => { + logger.info(`n8n Documentation MCP HTTP server started`); + logger.info(`HTTP endpoint: http://${this.config.host}:${this.config.port}`); + logger.info(`WebSocket endpoint: ws://${this.config.host}:${this.config.port}/mcp/websocket`); + logger.info(`Domain: ${this.config.domain}`); + resolve(); + }); + }); + } + + async stop(): Promise { + logger.info('Stopping n8n Documentation MCP HTTP server...'); + + // Close all WebSocket connections + this.wss.clients.forEach((ws: WebSocket) => ws.close()); + + // Close HTTP server + return new Promise((resolve) => { + this.server.close(() => { + this.nodeService.close(); + logger.info('Server stopped'); + resolve(); + }); + }); + } +} \ No newline at end of file diff --git a/src/mcp/remote-server.ts b/src/mcp/remote-server.ts new file mode 100644 index 0000000..bcd5fb0 --- /dev/null +++ b/src/mcp/remote-server.ts @@ -0,0 +1,608 @@ +import express from 'express'; +import { createServer as createHttpServer } from 'http'; +import { createServer as createHttpsServer } from 'https'; +import { + ErrorCode, + McpError, +} from '@modelcontextprotocol/sdk/types.js'; +import { NodeDocumentationService } from '../services/node-documentation-service'; +import { nodeDocumentationTools } from './tools-v2'; +import { logger } from '../utils/logger'; +import { authenticateRequest } from '../utils/auth-middleware'; +import * as fs from 'fs'; + +interface RemoteServerConfig { + port: number; + host: string; + domain: string; + authToken?: string; + cors?: boolean; + tlsCert?: string; + tlsKey?: string; +} + +/** + * Remote MCP Server using Streamable HTTP transport + * Based on MCP's modern approach for remote servers + */ +export class N8NDocumentationRemoteServer { + private app: express.Application; + private server: any; + private nodeService: NodeDocumentationService; + private config: RemoteServerConfig; + + constructor(config: RemoteServerConfig) { + this.config = config; + this.app = express(); + this.nodeService = new NodeDocumentationService(); + + this.setupMiddleware(); + this.setupRoutes(); + } + + private setupMiddleware(): void { + // Parse JSON bodies with larger limit for MCP messages + this.app.use(express.json({ limit: '10mb' })); + + // CORS if enabled + if (this.config.cors) { + this.app.use((req, res, next): void => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-ID'); + if (req.method === 'OPTIONS') { + res.sendStatus(200); + return; + } + next(); + }); + } + + // Request logging + this.app.use((req, res, next): void => { + logger.info(`${req.method} ${req.path}`, { + ip: req.ip, + userAgent: req.get('user-agent'), + requestId: req.get('X-Request-ID') + }); + next(); + }); + } + + private setupRoutes(): void { + // Health check endpoint + this.app.get('/health', (req, res) => { + res.json({ + status: 'ok', + service: 'n8n-documentation-mcp', + version: '2.0.0', + uptime: process.uptime(), + domain: this.config.domain + }); + }); + + // MCP info endpoint - provides server capabilities + this.app.get('/', (req, res) => { + res.json({ + name: 'n8n-node-documentation', + version: '2.0.0', + description: 'MCP server providing n8n node documentation and source code', + transport: 'http', + endpoint: `https://${this.config.domain}/mcp`, + authentication: this.config.authToken ? 'bearer-token' : 'none', + capabilities: { + tools: nodeDocumentationTools.map(t => ({ + name: t.name, + description: t.description + })), + resources: [ + { + uri: 'nodes://list', + name: 'Available n8n Nodes', + description: 'List of all available n8n nodes', + }, + { + uri: 'nodes://statistics', + name: 'Database Statistics', + description: 'Statistics about the node documentation database', + }, + ] + } + }); + }); + + // Database stats endpoint (public) + this.app.get('/stats', async (req, res) => { + try { + const stats = this.nodeService.getStatistics(); + res.json(stats); + } catch (error) { + logger.error('Failed to get statistics:', error); + res.status(500).json({ error: 'Failed to retrieve statistics' }); + } + }); + + // Rebuild endpoint (requires auth) + this.app.post('/rebuild', authenticateRequest(this.config.authToken), async (req, res) => { + try { + logger.info('Database rebuild requested'); + const stats = await this.nodeService.rebuildDatabase(); + res.json({ + message: 'Database rebuild complete', + stats + }); + } catch (error) { + logger.error('Rebuild failed:', error); + res.status(500).json({ error: 'Rebuild failed' }); + } + }); + + // Main MCP endpoint - handles all MCP protocol messages + this.app.post('/mcp', authenticateRequest(this.config.authToken), async (req, res) => { + const requestId = req.get('X-Request-ID') || 'unknown'; + + try { + // Process the JSON-RPC request directly + const response = await this.handleJsonRpcRequest(req.body); + res.json(response); + } catch (error) { + logger.error(`MCP request failed (${requestId}):`, error); + + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + id: req.body?.id || null, + error: { + code: -32603, + message: 'Internal error', + data: error instanceof Error ? error.message : String(error) + } + }); + } + } + }); + } + + private async handleJsonRpcRequest(request: any): Promise { + const { jsonrpc, method, params, id } = request; + + if (jsonrpc !== '2.0') { + return { + jsonrpc: '2.0', + id: id || null, + error: { + code: -32600, + message: 'Invalid Request', + data: 'JSON-RPC version must be "2.0"' + } + }; + } + + try { + let result; + + switch (method) { + case 'tools/list': + result = await this.handleListTools(); + break; + + case 'resources/list': + result = await this.handleListResources(); + break; + + case 'resources/read': + result = await this.handleReadResource(params); + break; + + case 'tools/call': + result = await this.handleToolCall(params); + break; + + default: + return { + jsonrpc: '2.0', + id: id || null, + error: { + code: -32601, + message: 'Method not found', + data: `Unknown method: ${method}` + } + }; + } + + return { + jsonrpc: '2.0', + id: id || null, + result + }; + } catch (error) { + logger.error(`Error handling method ${method}:`, error); + + const errorCode = error instanceof McpError ? error.code : -32603; + const errorMessage = error instanceof Error ? error.message : 'Internal error'; + + return { + jsonrpc: '2.0', + id: id || null, + error: { + code: errorCode, + message: errorMessage, + data: error instanceof McpError ? error.data : undefined + } + }; + } + } + + private async handleListTools(): Promise { + return { + tools: nodeDocumentationTools, + }; + } + + private async handleListResources(): Promise { + return { + resources: [ + { + uri: 'nodes://list', + name: 'Available n8n Nodes', + description: 'List of all available n8n nodes', + mimeType: 'application/json', + }, + { + uri: 'nodes://statistics', + name: 'Database Statistics', + description: 'Statistics about the node documentation database', + mimeType: 'application/json', + }, + ], + }; + } + + private async handleReadResource(params: any): Promise { + const { uri } = params; + + if (uri === 'nodes://list') { + const nodes = await this.nodeService.listNodes(); + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(nodes.map(n => ({ + nodeType: n.nodeType, + name: n.name, + displayName: n.displayName, + category: n.category, + description: n.description, + hasDocumentation: !!n.documentation, + hasExample: !!n.exampleWorkflow, + })), null, 2), + }, + ], + }; + } + + if (uri === 'nodes://statistics') { + const stats = this.nodeService.getStatistics(); + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(stats, null, 2), + }, + ], + }; + } + + throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${uri}`); + } + + private async handleToolCall(params: any): Promise { + const { name, arguments: args } = params; + + switch (name) { + case 'list_nodes': + return await this.handleListNodes(args); + + case 'get_node_info': + return await this.handleGetNodeInfo(args); + + case 'search_nodes': + return await this.handleSearchNodes(args); + + case 'get_node_example': + return await this.handleGetNodeExample(args); + + case 'get_node_source_code': + return await this.handleGetNodeSourceCode(args); + + case 'get_node_documentation': + return await this.handleGetNodeDocumentation(args); + + case 'rebuild_database': + return await this.handleRebuildDatabase(args); + + case 'get_database_statistics': + return await this.handleGetStatistics(); + + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); + } + } + + // Tool handlers + private async handleListNodes(args: any): Promise { + const nodes = await this.nodeService.listNodes(); + + let filtered = nodes; + + if (args.category) { + filtered = filtered.filter(n => n.category === args.category); + } + + if (args.packageName) { + filtered = filtered.filter(n => n.packageName === args.packageName); + } + + if (args.isTrigger !== undefined) { + filtered = filtered.filter(n => n.isTrigger === args.isTrigger); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filtered.map(n => ({ + nodeType: n.nodeType, + name: n.name, + displayName: n.displayName, + category: n.category, + description: n.description, + packageName: n.packageName, + hasDocumentation: !!n.documentation, + hasExample: !!n.exampleWorkflow, + isTrigger: n.isTrigger, + isWebhook: n.isWebhook, + })), null, 2), + }, + ], + }; + } + + private async handleGetNodeInfo(args: any): Promise { + if (!args.nodeType) { + throw new McpError(ErrorCode.InvalidParams, 'nodeType is required'); + } + + const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType); + + if (!nodeInfo) { + throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + nodeType: nodeInfo.nodeType, + name: nodeInfo.name, + displayName: nodeInfo.displayName, + description: nodeInfo.description, + category: nodeInfo.category, + packageName: nodeInfo.packageName, + sourceCode: nodeInfo.sourceCode, + credentialCode: nodeInfo.credentialCode, + documentation: nodeInfo.documentation, + documentationUrl: nodeInfo.documentationUrl, + exampleWorkflow: nodeInfo.exampleWorkflow, + exampleParameters: nodeInfo.exampleParameters, + propertiesSchema: nodeInfo.propertiesSchema, + isTrigger: nodeInfo.isTrigger, + isWebhook: nodeInfo.isWebhook, + }, null, 2), + }, + ], + }; + } + + private async handleSearchNodes(args: any): Promise { + if (!args.query) { + throw new McpError(ErrorCode.InvalidParams, 'query is required'); + } + + const results = await this.nodeService.searchNodes({ + query: args.query, + category: args.category, + limit: args.limit || 20, + }); + + let filtered = results; + if (args.hasDocumentation) { + filtered = filtered.filter(n => !!n.documentation); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filtered.map(n => ({ + nodeType: n.nodeType, + name: n.name, + displayName: n.displayName, + category: n.category, + description: n.description, + hasDocumentation: !!n.documentation, + hasExample: !!n.exampleWorkflow, + })), null, 2), + }, + ], + }; + } + + private async handleGetNodeExample(args: any): Promise { + if (!args.nodeType) { + throw new McpError(ErrorCode.InvalidParams, 'nodeType is required'); + } + + const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType); + + if (!nodeInfo) { + throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`); + } + + if (!nodeInfo.exampleWorkflow) { + return { + content: [ + { + type: 'text', + text: `No example available for node: ${args.nodeType}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(nodeInfo.exampleWorkflow, null, 2), + }, + ], + }; + } + + private async handleGetNodeSourceCode(args: any): Promise { + if (!args.nodeType) { + throw new McpError(ErrorCode.InvalidParams, 'nodeType is required'); + } + + const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType); + + if (!nodeInfo) { + throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`); + } + + const response: any = { + nodeType: nodeInfo.nodeType, + sourceCode: nodeInfo.sourceCode, + }; + + if (args.includeCredentials && nodeInfo.credentialCode) { + response.credentialCode = nodeInfo.credentialCode; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } + + private async handleGetNodeDocumentation(args: any): Promise { + if (!args.nodeType) { + throw new McpError(ErrorCode.InvalidParams, 'nodeType is required'); + } + + const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType); + + if (!nodeInfo) { + throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`); + } + + if (!nodeInfo.documentation) { + return { + content: [ + { + type: 'text', + text: `No documentation available for node: ${args.nodeType}`, + }, + ], + }; + } + + const content = args.format === 'plain' + ? nodeInfo.documentation.replace(/[#*`]/g, '') + : nodeInfo.documentation; + + return { + content: [ + { + type: 'text', + text: content, + }, + ], + }; + } + + private async handleRebuildDatabase(args: any): Promise { + logger.info('Database rebuild requested via MCP'); + + const stats = await this.nodeService.rebuildDatabase(); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Database rebuild complete', + stats, + }, null, 2), + }, + ], + }; + } + + private async handleGetStatistics(): Promise { + const stats = this.nodeService.getStatistics(); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(stats, null, 2), + }, + ], + }; + } + + async start(): Promise { + // Create server (HTTP or HTTPS) + if (this.config.tlsCert && this.config.tlsKey) { + const tlsOptions = { + cert: fs.readFileSync(this.config.tlsCert), + key: fs.readFileSync(this.config.tlsKey), + }; + this.server = createHttpsServer(tlsOptions, this.app); + } else { + this.server = createHttpServer(this.app); + } + + return new Promise((resolve) => { + this.server.listen(this.config.port, this.config.host, () => { + const protocol = this.config.tlsCert ? 'https' : 'http'; + logger.info(`n8n Documentation MCP Remote server started`); + logger.info(`Endpoint: ${protocol}://${this.config.host}:${this.config.port}`); + logger.info(`Domain: ${this.config.domain}`); + logger.info(`MCP endpoint: ${protocol}://${this.config.domain}/mcp`); + resolve(); + }); + }); + } + + async stop(): Promise { + logger.info('Stopping n8n Documentation MCP Remote server...'); + + return new Promise((resolve) => { + this.server.close(() => { + this.nodeService.close(); + logger.info('Server stopped'); + resolve(); + }); + }); + } +} \ No newline at end of file diff --git a/src/utils/auth-middleware.ts b/src/utils/auth-middleware.ts new file mode 100644 index 0000000..98b3060 --- /dev/null +++ b/src/utils/auth-middleware.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction } from 'express'; +import { logger } from './logger'; + +/** + * Express middleware for authenticating requests with Bearer tokens + */ +export function authenticateRequest(authToken?: string) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!authToken) { + // No auth required + return next(); + } + + const authHeader = req.headers['authorization']; + + if (!authHeader) { + logger.warn('Missing authorization header', { + ip: req.ip, + path: req.path, + }); + + res.status(401).json({ + error: 'Unauthorized', + message: 'Missing authorization header', + }); + return; + } + + // Support both "Bearer TOKEN" and just "TOKEN" formats + const providedToken = authHeader.startsWith('Bearer ') + ? authHeader.substring(7) + : authHeader; + + if (providedToken !== authToken) { + logger.warn('Invalid authentication token', { + ip: req.ip, + path: req.path, + }); + + res.status(401).json({ + error: 'Unauthorized', + message: 'Invalid authentication token', + }); + return; + } + + next(); + }; +} \ No newline at end of file