diff --git a/.env.example b/.env.example index 41052b3..a6bbfee 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ # Database Configuration # For local development: ./data/nodes.db # For Docker: /app/data/nodes.db +# Custom paths supported in v2.7.16+ (must end with .db) NODE_DB_PATH=./data/nodes.db # Logging Level (debug, info, warn, error) @@ -44,6 +45,15 @@ USE_FIXED_HTTP=true PORT=3000 HOST=0.0.0.0 +# Base URL Configuration (optional) +# Set this when running behind a proxy or when the server is accessed via a different URL +# than what it binds to. If not set, URLs will be auto-detected from proxy headers (if TRUST_PROXY is set) +# or constructed from HOST and PORT. +# Examples: +# BASE_URL=https://n8n-mcp.example.com +# BASE_URL=https://your-domain.com:8443 +# PUBLIC_URL=https://n8n-mcp.mydomain.com (alternative to BASE_URL) + # Authentication token for HTTP mode (REQUIRED) # Generate with: openssl rand -base64 32 AUTH_TOKEN=your-secure-token-here diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4a8e727 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# GitHub Funding Configuration + +github: [czlonkowski] diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 7861a25..31573b9 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -7,9 +7,25 @@ on: - main tags: - 'v*' + paths-ignore: + - '**.md' + - '.github/FUNDING.yml' + - '.github/ISSUE_TEMPLATE/**' + - '.github/pull_request_template.md' + - 'LICENSE' + - 'ATTRIBUTION.md' + - 'docs/**' pull_request: branches: - main + paths-ignore: + - '**.md' + - '.github/FUNDING.yml' + - '.github/ISSUE_TEMPLATE/**' + - '.github/pull_request_template.md' + - 'LICENSE' + - 'ATTRIBUTION.md' + - 'docs/**' workflow_dispatch: env: @@ -56,7 +72,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha,prefix={{branch}}-,format=short + type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image diff --git a/Dockerfile.railway b/Dockerfile.railway index 341fead..40f31f1 100644 --- a/Dockerfile.railway +++ b/Dockerfile.railway @@ -85,4 +85,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ # Optimized entrypoint (identical to main Dockerfile) ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] -CMD ["node", "dist/mcp/index.js", "--http"] +CMD ["node", "dist/mcp/index.js", "--http"] \ No newline at end of file diff --git a/data/nodes.db b/data/nodes.db index 7efa4be..4bcc122 100644 Binary files a/data/nodes.db and b/data/nodes.db differ diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 363ee5e..0311468 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,39 +1,75 @@ #!/bin/sh set -e +# Helper function for safe logging (prevents stdio mode corruption) +log_message() { + [ "$MCP_MODE" != "stdio" ] && echo "$@" +} + # Environment variable validation if [ "$MCP_MODE" = "http" ] && [ -z "$AUTH_TOKEN" ] && [ -z "$AUTH_TOKEN_FILE" ]; then - echo "ERROR: AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode" + log_message "ERROR: AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode" >&2 exit 1 fi # Validate AUTH_TOKEN_FILE if provided if [ -n "$AUTH_TOKEN_FILE" ] && [ ! -f "$AUTH_TOKEN_FILE" ]; then - echo "ERROR: AUTH_TOKEN_FILE specified but file not found: $AUTH_TOKEN_FILE" + log_message "ERROR: AUTH_TOKEN_FILE specified but file not found: $AUTH_TOKEN_FILE" >&2 exit 1 fi +# Database path configuration - respect NODE_DB_PATH if set +if [ -n "$NODE_DB_PATH" ]; then + # Basic validation - must end with .db + case "$NODE_DB_PATH" in + *.db) ;; + *) log_message "ERROR: NODE_DB_PATH must end with .db" >&2; exit 1 ;; + esac + + # Use the path as-is (Docker paths should be absolute anyway) + DB_PATH="$NODE_DB_PATH" +else + DB_PATH="/app/data/nodes.db" +fi + +DB_DIR=$(dirname "$DB_PATH") + +# Ensure database directory exists with correct ownership +if [ ! -d "$DB_DIR" ]; then + log_message "Creating database directory: $DB_DIR" + if [ "$(id -u)" = "0" ]; then + # Create as root but immediately fix ownership + mkdir -p "$DB_DIR" && chown nodejs:nodejs "$DB_DIR" + else + mkdir -p "$DB_DIR" + fi +fi + # Database initialization with file locking to prevent race conditions -if [ ! -f "/app/data/nodes.db" ]; then - echo "Database not found. Initializing..." +if [ ! -f "$DB_PATH" ]; then + log_message "Database not found at $DB_PATH. Initializing..." # Use a lock file to prevent multiple containers from initializing simultaneously ( flock -x 200 # Double-check inside the lock - if [ ! -f "/app/data/nodes.db" ]; then - echo "Initializing database..." - cd /app && node dist/scripts/rebuild.js || { - echo "ERROR: Database initialization failed" + if [ ! -f "$DB_PATH" ]; then + log_message "Initializing database at $DB_PATH..." + cd /app && NODE_DB_PATH="$DB_PATH" node dist/scripts/rebuild.js || { + log_message "ERROR: Database initialization failed" >&2 exit 1 } fi - ) 200>/app/data/.db.lock + ) 200>"$DB_DIR/.db.lock" fi # Fix permissions if running as root (for development) if [ "$(id -u)" = "0" ]; then - echo "Running as root, fixing permissions..." - chown -R nodejs:nodejs /app/data + log_message "Running as root, fixing permissions..." + chown -R nodejs:nodejs "$DB_DIR" + # Also ensure /app/data exists for backward compatibility + if [ -d "/app/data" ]; then + chown -R nodejs:nodejs /app/data + fi # Switch to nodejs user (using Alpine's native su) exec su nodejs -c "$*" fi diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9f50f62..d8eb66f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.7.15] - 2025-07-15 + +### Fixed +- **HTTP Server URL Handling**: Fixed hardcoded localhost URLs in HTTP server output (Issue #41, #42) + - Added intelligent URL detection that considers BASE_URL, PUBLIC_URL, and proxy headers + - Server now displays correct public URLs when deployed behind reverse proxies + - Added support for X-Forwarded-Proto and X-Forwarded-Host headers when TRUST_PROXY is enabled + - Fixed port display logic to hide standard ports (80/443) in URLs + - Added new GET endpoints (/, /mcp) for better API discovery + +### Security +- **Host Header Injection Prevention**: Added hostname validation to prevent malicious proxy headers + - Only accepts valid hostnames (alphanumeric, dots, hyphens, optional port) + - Rejects hostnames with paths, usernames, or special characters + - Falls back to safe defaults when invalid headers are detected +- **URL Scheme Validation**: Restricted URL schemes to http/https only + - Blocks dangerous schemes like javascript:, file://, data: + - Validates all configured URLs (BASE_URL, PUBLIC_URL) +- **Information Disclosure**: Removed sensitive environment data from API responses + - Root endpoint no longer exposes internal configuration + - Only shows essential API information + +### Added +- **URL Detection Utility**: New `url-detector.ts` module for intelligent URL detection + - Prioritizes explicit configuration (BASE_URL/PUBLIC_URL) + - Falls back to proxy headers when TRUST_PROXY is enabled + - Uses host/port configuration as final fallback + - Includes comprehensive security validations +- **Test Scripts**: Added test scripts for URL configuration and security validation + - `test-url-configuration.ts`: Tests various URL detection scenarios + - `test-security.ts`: Validates security fixes for malicious headers + +### Changed +- **Consistent Versioning**: Fixed version inconsistency between server implementations + - Both http-server.ts and http-server-single-session.ts now use PROJECT_VERSION + - Removed hardcoded version strings +- **HTTP Bridge**: Updated to use HOST/PORT environment variables for default URL construction +- **Documentation**: Updated HTTP deployment guide with URL configuration section + +## [2.7.14] - 2025-07-15 + +### Fixed +- **Partial Update Tool**: Fixed validation/execution discrepancy that caused "settings must NOT have additional properties" error (Issue #45) + - Removed logic in `cleanWorkflowForUpdate` that was incorrectly adding default settings to workflows + - The function now only removes read-only fields without adding any new properties + - This fixes the issue where partial updates would pass validation but fail during execution + - Added comprehensive test coverage in `test-issue-45-fix.ts` + ## [2.7.13] - 2025-07-11 ### Fixed diff --git a/docs/CLAUDE_CODE_SETUP.md b/docs/CLAUDE_CODE_SETUP.md new file mode 100644 index 0000000..8004def --- /dev/null +++ b/docs/CLAUDE_CODE_SETUP.md @@ -0,0 +1,94 @@ +# Claude Code Setup + +Connect n8n-MCP to Claude Code CLI for enhanced n8n workflow development from the command line. + +## Quick Setup via CLI + +### Basic configuration (documentation tools only): +```bash +claude mcp add n8n-mcp \ + -e MCP_MODE=stdio \ + -e LOG_LEVEL=error \ + -e DISABLE_CONSOLE_OUTPUT=true \ + -- npx n8n-mcp +``` + +![Adding n8n-MCP server in Claude Code](./img/cc_command.png) + +### Full configuration (with n8n management tools): +```bash +claude mcp add n8n-mcp \ + -e MCP_MODE=stdio \ + -e LOG_LEVEL=error \ + -e DISABLE_CONSOLE_OUTPUT=true \ + -e N8N_API_URL=https://your-n8n-instance.com \ + -e N8N_API_KEY=your-api-key \ + -- npx n8n-mcp +``` + +Make sure to replace `https://your-n8n-instance.com` with your actual n8n URL and `your-api-key` with your n8n API key. + +## Alternative Setup Methods + +### Option 1: Import from Claude Desktop + +If you already have n8n-MCP configured in Claude Desktop: +```bash +claude mcp add-from-claude-desktop +``` + +### Option 2: Project Configuration + +For team sharing, add to `.mcp.json` in your project root: +```json +{ + "servers": { + "n8n-mcp": { + "command": "npx", + "args": ["n8n-mcp"], + "env": { + "MCP_MODE": "stdio", + "LOG_LEVEL": "error", + "DISABLE_CONSOLE_OUTPUT": "true", + "N8N_API_URL": "https://your-n8n-instance.com", + "N8N_API_KEY": "your-api-key" + } + } + } +} +``` + +Then use with scope flag: +```bash +claude mcp add n8n-mcp --scope project +``` + +## Managing Your MCP Server + +Check server status: +```bash +claude mcp list +claude mcp get n8n-mcp +``` + +During a conversation, use the `/mcp` command to see server status and available tools. + +![n8n-MCP connected and showing 39 tools available](./img/cc_connected.png) + +Remove the server: +```bash +claude mcp remove n8n-mcp +``` + +## Project Instructions + +For optimal results, create a `CLAUDE.md` file in your project root with the instructions from the [main README's Claude Project Setup section](../README.md#-claude-project-setup). + +## Tips + +- If you're running n8n locally, use `http://localhost:5678` as the N8N_API_URL +- The n8n API credentials are optional - without them, you'll have documentation and validation tools only +- With API credentials, you'll get full workflow management capabilities +- Use `--scope local` (default) to keep your API credentials private +- Use `--scope project` to share configuration with your team (put credentials in environment variables) +- Claude Code will automatically start the MCP server when you begin a conversation \ No newline at end of file diff --git a/docs/CURSOR_SETUP.md b/docs/CURSOR_SETUP.md new file mode 100644 index 0000000..d1cdfff --- /dev/null +++ b/docs/CURSOR_SETUP.md @@ -0,0 +1,73 @@ +# Cursor Setup + +Connect n8n-MCP to Cursor IDE for enhanced n8n workflow development with AI assistance. + +[![n8n-mcp Cursor Setup Tutorial](./img/cursor_tut.png)](https://www.youtube.com/watch?v=hRmVxzLGJWI) + +## Video Tutorial + +Watch the complete setup process: [n8n-MCP Cursor Setup Tutorial](https://www.youtube.com/watch?v=hRmVxzLGJWI) + +## Setup Process + +### 1. Create MCP Configuration + +1. Create a `.cursor` folder in your project root +2. Create `mcp.json` file inside the `.cursor` folder +3. Copy the configuration from this repository + +**Basic configuration (documentation tools only):** +```json +{ + "mcpServers": { + "n8n-mcp": { + "command": "npx", + "args": ["n8n-mcp"], + "env": { + "MCP_MODE": "stdio", + "LOG_LEVEL": "error", + "DISABLE_CONSOLE_OUTPUT": "true" + } + } + } +} +``` + +**Full configuration (with n8n management tools):** +```json +{ + "mcpServers": { + "n8n-mcp": { + "command": "npx", + "args": ["n8n-mcp"], + "env": { + "MCP_MODE": "stdio", + "LOG_LEVEL": "error", + "DISABLE_CONSOLE_OUTPUT": "true", + "N8N_API_URL": "https://your-n8n-instance.com", + "N8N_API_KEY": "your-api-key" + } + } + } +} +``` + +### 2. Configure n8n Connection + +1. Replace `https://your-n8n-instance.com` with your actual n8n URL +2. Replace `your-api-key` with your n8n API key + +### 3. Enable MCP Server + +1. Click "Enable MCP Server" button in Cursor +2. Go to Cursor Settings +3. Search for "mcp" +4. Confirm MCP is working + +### 4. Set Up Project Instructions + +1. In your Cursor chat, invoke "create rule" and hit Tab +2. Name the rule (e.g., "n8n-mcp") +3. Set rule type to "always" +4. Copy the Claude Project instructions from the [main README's Claude Project Setup section](../README.md#-claude-project-setup) + diff --git a/docs/DOCKER_README.md b/docs/DOCKER_README.md index d773579..d31d385 100644 --- a/docs/DOCKER_README.md +++ b/docs/DOCKER_README.md @@ -64,6 +64,7 @@ docker run -d \ | `PORT` | HTTP server port | `3000` | No | | `NODE_ENV` | Environment: `development` or `production` | `production` | No | | `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, `error` | `info` | No | +| `NODE_DB_PATH` | Custom database path (v2.7.16+) | `/app/data/nodes.db` | No | *Either `AUTH_TOKEN` or `AUTH_TOKEN_FILE` must be set for HTTP mode. If both are set, `AUTH_TOKEN` takes precedence. @@ -342,6 +343,28 @@ docker run --rm \ alpine tar xzf /backup/n8n-mcp-backup.tar.gz -C /target ``` +### Custom Database Path (v2.7.16+) + +You can specify a custom database location using `NODE_DB_PATH`: + +```bash +# Use custom path within mounted volume +docker run -d \ + --name n8n-mcp \ + -e MCP_MODE=http \ + -e AUTH_TOKEN=your-token \ + -e NODE_DB_PATH=/app/data/custom/my-nodes.db \ + -v n8n-mcp-data:/app/data \ + -p 3000:3000 \ + ghcr.io/czlonkowski/n8n-mcp:latest +``` + +**Important Notes:** +- The path must end with `.db` +- For data persistence, ensure the path is within a mounted volume +- Paths outside mounted volumes will be lost on container restart +- The directory will be created automatically if it doesn't exist + ## πŸ› Troubleshooting ### Common Issues @@ -506,4 +529,4 @@ services: --- -*Last updated: June 2025 - Docker implementation v1.0* \ No newline at end of file +*Last updated: July 2025 - Docker implementation v1.1* \ No newline at end of file diff --git a/docs/DOCKER_TROUBLESHOOTING.md b/docs/DOCKER_TROUBLESHOOTING.md new file mode 100644 index 0000000..d0adc23 --- /dev/null +++ b/docs/DOCKER_TROUBLESHOOTING.md @@ -0,0 +1,349 @@ +# Docker Troubleshooting Guide + +This guide helps resolve common issues when running n8n-mcp with Docker, especially when connecting to n8n instances. + +## Table of Contents +- [Common Issues](#common-issues) + - [502 Bad Gateway Errors](#502-bad-gateway-errors) + - [Custom Database Path Not Working](#custom-database-path-not-working-v27160) + - [Container Name Conflicts](#container-name-conflicts) + - [n8n API Connection Issues](#n8n-api-connection-issues) +- [Docker Networking](#docker-networking) +- [Quick Solutions](#quick-solutions) +- [Debugging Steps](#debugging-steps) + +## Common Issues + +### Custom Database Path Not Working (v2.7.16+) + +**Symptoms:** +- `NODE_DB_PATH` environment variable is set but ignored +- Database always created at `/app/data/nodes.db` +- Custom path setting has no effect + +**Root Cause:** Fixed in v2.7.16. Earlier versions had hardcoded paths in docker-entrypoint.sh. + +**Solutions:** + +1. **Update to v2.7.16 or later:** +```bash +docker pull ghcr.io/czlonkowski/n8n-mcp:latest +``` + +2. **Ensure path ends with .db:** +```bash +# Correct +NODE_DB_PATH=/app/data/custom/my-nodes.db + +# Incorrect (will be rejected) +NODE_DB_PATH=/app/data/custom/my-nodes +``` + +3. **Use path within mounted volume for persistence:** +```yaml +services: + n8n-mcp: + environment: + NODE_DB_PATH: /app/data/custom/nodes.db + volumes: + - n8n-mcp-data:/app/data # Ensure parent directory is mounted +``` + +### 502 Bad Gateway Errors + +**Symptoms:** +- `n8n_health_check` returns 502 error +- All n8n management API calls fail +- n8n web UI is accessible but API is not + +**Root Cause:** Network connectivity issues between n8n-mcp container and n8n instance. + +**Solutions:** + +#### 1. When n8n runs in Docker on same machine + +Use Docker's special hostnames instead of `localhost`: + +```json +{ + "mcpServers": { + "n8n-mcp": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "N8N_API_URL=http://host.docker.internal:5678", + "-e", "N8N_API_KEY=your-api-key", + "ghcr.io/czlonkowski/n8n-mcp:latest" + ] + } + } +} +``` + +**Alternative hostnames to try:** +- `host.docker.internal` (Docker Desktop on macOS/Windows) +- `172.17.0.1` (Default Docker bridge IP on Linux) +- Your machine's actual IP address (e.g., `192.168.1.100`) + +#### 2. When both containers are in same Docker network + +```bash +# Create a shared network +docker network create n8n-network + +# Run n8n in the network +docker run -d --name n8n --network n8n-network -p 5678:5678 n8nio/n8n + +# Configure n8n-mcp to use container name +``` + +```json +{ + "N8N_API_URL": "http://n8n:5678" +} +``` + +#### 3. For Docker Compose setups + +```yaml +# docker-compose.yml +services: + n8n: + image: n8nio/n8n + container_name: n8n + networks: + - n8n-net + ports: + - "5678:5678" + + n8n-mcp: + image: ghcr.io/czlonkowski/n8n-mcp:latest + environment: + N8N_API_URL: http://n8n:5678 + N8N_API_KEY: ${N8N_API_KEY} + networks: + - n8n-net + +networks: + n8n-net: + driver: bridge +``` + +### Container Name Conflicts + +**Symptoms:** +- Error: "Container with name '/n8n-mcp-container' already exists" +- Claude Desktop shows duplicate containers + +**Root Cause:** Claude Desktop bug that spawns containers twice. + +**Solutions:** + +1. **Remove container name (Recommended):** +```json +{ + "command": "docker", + "args": [ + "run", "-i", "--rm", + // Remove: "--name", "n8n-mcp-container", + "ghcr.io/czlonkowski/n8n-mcp:latest" + ] +} +``` + +2. **Manual cleanup when it happens:** +```bash +docker rm -f n8n-mcp-container +``` + +3. **Use HTTP mode instead** (avoids the issue entirely) + +### n8n API Connection Issues + +**Symptoms:** +- API calls fail but n8n web UI works +- Authentication errors +- API endpoints return 404 + +**Solutions:** + +1. **Verify n8n API is enabled:** + - Check n8n settings β†’ REST API is enabled + - Ensure API key is valid and not expired + +2. **Test API directly:** +```bash +# From host machine +curl -H "X-N8N-API-KEY: your-key" http://localhost:5678/api/v1/workflows + +# From inside Docker container +docker run --rm curlimages/curl \ + -H "X-N8N-API-KEY: your-key" \ + http://host.docker.internal:5678/api/v1/workflows +``` + +3. **Check n8n environment variables:** +```yaml +environment: + - N8N_BASIC_AUTH_ACTIVE=true + - N8N_BASIC_AUTH_USER=user + - N8N_BASIC_AUTH_PASSWORD=password +``` + +## Docker Networking + +### Understanding Docker Network Modes + +| Scenario | Use This URL | Why | +|----------|--------------|-----| +| n8n on host, n8n-mcp in Docker | `http://host.docker.internal:5678` | Docker can't reach host's localhost | +| Both in same Docker network | `http://container-name:5678` | Direct container-to-container | +| n8n behind reverse proxy | `http://your-domain.com` | Use public URL | +| Local development | `http://YOUR_LOCAL_IP:5678` | Use machine's IP address | + +### Finding Your Configuration + +```bash +# Check if n8n is running in Docker +docker ps | grep n8n + +# Find Docker network +docker network ls + +# Get container details +docker inspect n8n | grep NetworkMode + +# Find your local IP +# macOS/Linux +ifconfig | grep "inet " | grep -v 127.0.0.1 + +# Windows +ipconfig | findstr IPv4 +``` + +## Quick Solutions + +### Solution 1: Use Host Network (Linux only) +```json +{ + "command": "docker", + "args": [ + "run", "-i", "--rm", + "--network", "host", + "-e", "N8N_API_URL=http://localhost:5678", + "ghcr.io/czlonkowski/n8n-mcp:latest" + ] +} +``` + +### Solution 2: Use Your Machine's IP +```json +{ + "N8N_API_URL": "http://192.168.1.100:5678" // Replace with your IP +} +``` + +### Solution 3: HTTP Mode Deployment +Deploy n8n-mcp as HTTP server to avoid stdio/Docker issues: + +```bash +# Start HTTP server +docker run -d \ + -p 3000:3000 \ + -e MCP_MODE=http \ + -e AUTH_TOKEN=your-token \ + -e N8N_API_URL=http://host.docker.internal:5678 \ + -e N8N_API_KEY=your-n8n-key \ + ghcr.io/czlonkowski/n8n-mcp:latest + +# Configure Claude with mcp-remote +``` + +## Debugging Steps + +### 1. Enable Debug Logging +```json +{ + "env": { + "LOG_LEVEL": "debug", + "DEBUG_MCP": "true" + } +} +``` + +### 2. Test Connectivity +```bash +# Test from n8n-mcp container +docker run --rm ghcr.io/czlonkowski/n8n-mcp:latest \ + sh -c "apk add curl && curl -v http://host.docker.internal:5678/api/v1/workflows" +``` + +### 3. Check Docker Logs +```bash +# View n8n-mcp logs +docker logs $(docker ps -q -f ancestor=ghcr.io/czlonkowski/n8n-mcp:latest) + +# View n8n logs +docker logs n8n +``` + +### 4. Validate Environment +```bash +# Check what n8n-mcp sees +docker run --rm ghcr.io/czlonkowski/n8n-mcp:latest \ + sh -c "env | grep N8N" +``` + +### 5. Network Diagnostics +```bash +# Check Docker networks +docker network inspect bridge + +# Test DNS resolution +docker run --rm busybox nslookup host.docker.internal +``` + +## Platform-Specific Notes + +### Docker Desktop (macOS/Windows) +- `host.docker.internal` works out of the box +- Ensure Docker Desktop is running +- Check Docker Desktop settings β†’ Resources β†’ Network + +### Linux +- `host.docker.internal` requires Docker 20.10+ +- Alternative: Use `--add-host=host.docker.internal:host-gateway` +- Or use the Docker bridge IP: `172.17.0.1` + +### Windows with WSL2 +- Use `host.docker.internal` or WSL2 IP +- Check firewall rules for port 5678 +- Ensure n8n binds to `0.0.0.0` not `127.0.0.1` + +## Still Having Issues? + +1. **Check n8n logs** for API-related errors +2. **Verify firewall/security** isn't blocking connections +3. **Try simpler setup** - Run n8n-mcp on host instead of Docker +4. **Report issue** with debug logs at [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues) + +## Useful Commands + +```bash +# Remove all n8n-mcp containers +docker rm -f $(docker ps -aq -f ancestor=ghcr.io/czlonkowski/n8n-mcp:latest) + +# Test n8n API with curl +curl -H "X-N8N-API-KEY: your-key" http://localhost:5678/api/v1/workflows + +# Run interactive debug session +docker run -it --rm \ + -e LOG_LEVEL=debug \ + -e N8N_API_URL=http://host.docker.internal:5678 \ + -e N8N_API_KEY=your-key \ + ghcr.io/czlonkowski/n8n-mcp:latest \ + sh + +# Check container networking +docker run --rm alpine ping -c 4 host.docker.internal +``` \ No newline at end of file diff --git a/docs/HTTP_DEPLOYMENT.md b/docs/HTTP_DEPLOYMENT.md index 9b1c255..7534935 100644 --- a/docs/HTTP_DEPLOYMENT.md +++ b/docs/HTTP_DEPLOYMENT.md @@ -151,6 +151,8 @@ Skip HTTP entirely and use stdio mode directly: | `LOG_LEVEL` | Log verbosity | `info` | | `NODE_ENV` | Environment | `production` | | `TRUST_PROXY` | Trust proxy headers for correct IP logging | `0` | +| `BASE_URL` | Public URL for the server (v2.7.14+) | Auto-detected | +| `PUBLIC_URL` | Alternative to BASE_URL | Auto-detected | ### n8n Management Tools (Optional) @@ -200,6 +202,39 @@ When configured, you get **16 additional tools** (total: 38 tools): ## 🌐 Reverse Proxy Configuration +### URL Configuration (v2.7.14+) + +n8n-MCP now intelligently detects the correct URL for your deployment: + +1. **Explicit Configuration** (highest priority): + ```bash + BASE_URL=https://n8n-mcp.example.com # Explicitly set public URL + # or + PUBLIC_URL=https://your-domain.com:8443 + ``` + +2. **Auto-Detection from Proxy Headers** (when TRUST_PROXY is enabled): + - Detects from `X-Forwarded-Proto` and `X-Forwarded-Host` headers + - Perfect for Cloudflare, Nginx, and other proxies + +3. **Fallback** (when not configured): + - Uses `HOST` and `PORT` configuration + - Shows `localhost` when bound to `0.0.0.0` + +**Example scenarios:** +```bash +# Behind Cloudflare (auto-detected) +TRUST_PROXY=1 +# Console shows: https://n8n-mcp.example.com + +# Explicit configuration +BASE_URL=https://api.mycompany.com/mcp +# Console shows: https://api.mycompany.com/mcp + +# Local development (no proxy) +# Console shows: http://localhost:3000 +``` + ### Trust Proxy for Correct IP Logging When running n8n-MCP behind a reverse proxy (Nginx, Traefik, etc.), enable trust proxy to log real client IPs instead of proxy IPs: diff --git a/docs/VS_CODE_PROJECT_SETUP.md b/docs/VS_CODE_PROJECT_SETUP.md new file mode 100644 index 0000000..11b6fee --- /dev/null +++ b/docs/VS_CODE_PROJECT_SETUP.md @@ -0,0 +1,201 @@ +# Visual Studio Code Setup + +:white_check_mark: This n8n MCP server is compatible with VS Code + GitHub Copilot (Chat in IDE). + +## Preconditions + +Assuming you've already deployed the n8n MCP server and connected it to the n8n API, and it's available at: +`https://n8n.your.production.url/` + +πŸ’‘ The deployment process is documented in the [HTTP Deployment Guide](./HTTP_DEPLOYMENT.md). + +## Step 1 + +Start by creating a new VS Code project folder. + +## Step 2 + +Create a file: `.vscode/mcp.json` +```json +{ + "inputs": [ + { + "type": "promptString", + "id": "n8n-mcp-token", + "description": "Your n8n-MCP AUTH_TOKEN", + "password": true + } + ], + "servers": { + "n8n-mcp": { + "type": "http", + "url": "https://n8n.your.production.url/mcp", + "headers": { + "Authorization": "Bearer ${input:n8n-mcp-token}" + } + } + } +} +``` + +πŸ’‘ The `inputs` block ensures the token is requested interactively β€” no need to hardcode secrets. + +## Step 3 + +GitHub Copilot does not provide access to "thinking models" for unpaid users. To improve results, install the official [Sequential Thinking MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking) referenced in the [VS Code docs](https://code.visualstudio.com/mcp#:~:text=Install%20Linear-,Sequential%20Thinking,-Model%20Context%20Protocol). This lightweight add-on can turn any LLM into a thinking model by enabling step-by-step reasoning. It's highly recommended to use the n8n-mcp server in combination with a sequential thinking model to generate more accurate outputs. + +πŸ”§ Alternatively, you can try enabling this setting in Copilot to unlock "thinking mode" behavior: + +![VS Code Settings > GitHub > Copilot > Chat > Agent: Thinking Tool](./img/vsc_ghcp_chat_thinking_tool.png) + +_(Note: I haven’t tested this setting myself, as I use the Sequential Thinking MCP instead)_ + +## Step 4 + +For the best results when using n8n-MCP with VS Code, use these enhanced system instructions (copy to your project’s `.github/copilot-instructions.md`): + +```markdown +You are an expert in n8n automation software using n8n-MCP tools. Your role is to design, build, and validate n8n workflows with maximum accuracy and efficiency. + +## Core Workflow Process + +1. **ALWAYS start new conversation with**: `tools_documentation()` to understand best practices and available tools. + +2. **Discovery Phase** - Find the right nodes: + - Think deeply about user request and the logic you are going to build to fulfill it. Ask follow-up questions to clarify the user's intent, if something is unclear. Then, proceed with the rest of your instructions. + - `search_nodes({query: 'keyword'})` - Search by functionality + - `list_nodes({category: 'trigger'})` - Browse by category + - `list_ai_tools()` - See AI-capable nodes (remember: ANY node can be an AI tool!) + +3. **Configuration Phase** - Get node details efficiently: + - `get_node_essentials(nodeType)` - Start here! Only 10-20 essential properties + - `search_node_properties(nodeType, 'auth')` - Find specific properties + - `get_node_for_task('send_email')` - Get pre-configured templates + - `get_node_documentation(nodeType)` - Human-readable docs when needed + - It is good common practice to show a visual representation of the workflow architecture to the user and asking for opinion, before moving forward. + +4. **Pre-Validation Phase** - Validate BEFORE building: + - `validate_node_minimal(nodeType, config)` - Quick required fields check + - `validate_node_operation(nodeType, config, profile)` - Full operation-aware validation + - Fix any validation errors before proceeding + +5. **Building Phase** - Create the workflow: + - Use validated configurations from step 4 + - Connect nodes with proper structure + - Add error handling where appropriate + - Use expressions like $json, $node["NodeName"].json + - Build the workflow in an artifact for easy editing downstream (unless the user asked to create in n8n instance) + +6. **Workflow Validation Phase** - Validate complete workflow: + - `validate_workflow(workflow)` - Complete validation including connections + - `validate_workflow_connections(workflow)` - Check structure and AI tool connections + - `validate_workflow_expressions(workflow)` - Validate all n8n expressions + - Fix any issues found before deployment + +7. **Deployment Phase** (if n8n API configured): + - `n8n_create_workflow(workflow)` - Deploy validated workflow + - `n8n_validate_workflow({id: 'workflow-id'})` - Post-deployment validation + - `n8n_update_partial_workflow()` - Make incremental updates using diffs + - `n8n_trigger_webhook_workflow()` - Test webhook workflows + +## Key Insights + +- **USE CODE NODE ONLY WHEN IT IS NECESSARY** - always prefer to use standard nodes over code node. Use code node only when you are sure you need it. +- **VALIDATE EARLY AND OFTEN** - Catch errors before they reach deployment +- **USE DIFF UPDATES** - Use n8n_update_partial_workflow for 80-90% token savings +- **ANY node can be an AI tool** - not just those with usableAsTool=true +- **Pre-validate configurations** - Use validate_node_minimal before building +- **Post-validate workflows** - Always validate complete workflows before deployment +- **Incremental updates** - Use diff operations for existing workflows +- **Test thoroughly** - Validate both locally and after deployment to n8n + +## Validation Strategy + +### Before Building: +1. validate_node_minimal() - Check required fields +2. validate_node_operation() - Full configuration validation +3. Fix all errors before proceeding + +### After Building: +1. validate_workflow() - Complete workflow validation +2. validate_workflow_connections() - Structure validation +3. validate_workflow_expressions() - Expression syntax check + +### After Deployment: +1. n8n_validate_workflow({id}) - Validate deployed workflow +2. n8n_list_executions() - Monitor execution status +3. n8n_update_partial_workflow() - Fix issues using diffs + +## Response Structure + +1. **Discovery**: Show available nodes and options +2. **Pre-Validation**: Validate node configurations first +3. **Configuration**: Show only validated, working configs +4. **Building**: Construct workflow with validated components +5. **Workflow Validation**: Full workflow validation results +6. **Deployment**: Deploy only after all validations pass +7. **Post-Validation**: Verify deployment succeeded + +## Example Workflow + +### 1. Discovery & Configuration +search_nodes({query: 'slack'}) +get_node_essentials('n8n-nodes-base.slack') + +### 2. Pre-Validation +validate_node_minimal('n8n-nodes-base.slack', {resource:'message', operation:'send'}) +validate_node_operation('n8n-nodes-base.slack', fullConfig, 'runtime') + +### 3. Build Workflow +// Create workflow JSON with validated configs + +### 4. Workflow Validation +validate_workflow(workflowJson) +validate_workflow_connections(workflowJson) +validate_workflow_expressions(workflowJson) + +### 5. Deploy (if configured) +n8n_create_workflow(validatedWorkflow) +n8n_validate_workflow({id: createdWorkflowId}) + +### 6. Update Using Diffs +n8n_update_partial_workflow({ + workflowId: id, + operations: [ + {type: 'updateNode', nodeId: 'slack1', changes: {position: [100, 200]}} + ] +}) + +## Important Rules + +- ALWAYS validate before building +- ALWAYS validate after building +- NEVER deploy unvalidated workflows +- USE diff operations for updates (80-90% token savings) +- STATE validation results clearly +- FIX all errors before proceeding +``` + +This helps the agent produce higher-quality, well-structured n8n workflows. + +πŸ”§ Important: To ensure the instructions are always included, make sure this checkbox is enabled in your Copilot settings: + +![VS Code Settings > GitHub > Copilot > Chat > Code Generation: Use Instruction Files](./img/vsc_ghcp_chat_instruction_files.png) + +## Step 5 + +Switch GitHub Copilot to Agent mode: + +![VS Code > GitHub Copilot Chat > Edit files in your workspace in agent mode](./img/vsc_ghcp_chat_agent_mode.png) + +## Step 6 - Try it! + +Here’s an example prompt I used: +``` +#fetch https://blog.n8n.io/rag-chatbot/ + +use #sequentialthinking and #n8n-mcp tools to build a new n8n workflow step-by-step following the guidelines in the blog. +In the end, please deploy a fully-functional n8n workflow. +``` + +πŸ§ͺ My result wasn’t perfect (a bit messy workflow), but I'm genuinely happy that it created anything autonomously πŸ˜„ Stay tuned for updates! diff --git a/docs/WINDSURF_SETUP.md b/docs/WINDSURF_SETUP.md new file mode 100644 index 0000000..c70c140 --- /dev/null +++ b/docs/WINDSURF_SETUP.md @@ -0,0 +1,69 @@ +# Windsurf Setup + +Connect n8n-MCP to Windsurf IDE for enhanced n8n workflow development with AI assistance. + +[![n8n-mcp Windsurf Setup Tutorial](./img/windsurf_tut.png)](https://www.youtube.com/watch?v=klxxT1__izg) + +## Video Tutorial + +Watch the complete setup process: [n8n-MCP Windsurf Setup Tutorial](https://www.youtube.com/watch?v=klxxT1__izg) + +## Setup Process + +### 1. Access MCP Configuration + +1. Go to Settings in Windsurf +2. Navigate to Windsurf Settings +3. Go to MCP Servers > Manage Plugins +4. Click "View Raw Config" + +### 2. Add n8n-MCP Configuration + +Copy the configuration from this repository and add it to your MCP config: + +**Basic configuration (documentation tools only):** +```json +{ + "mcpServers": { + "n8n-mcp": { + "command": "npx", + "args": ["n8n-mcp"], + "env": { + "MCP_MODE": "stdio", + "LOG_LEVEL": "error", + "DISABLE_CONSOLE_OUTPUT": "true" + } + } + } +} +``` + +**Full configuration (with n8n management tools):** +```json +{ + "mcpServers": { + "n8n-mcp": { + "command": "npx", + "args": ["n8n-mcp"], + "env": { + "MCP_MODE": "stdio", + "LOG_LEVEL": "error", + "DISABLE_CONSOLE_OUTPUT": "true", + "N8N_API_URL": "https://your-n8n-instance.com", + "N8N_API_KEY": "your-api-key" + } + } + } +} +``` + +### 3. Configure n8n Connection + +1. Replace `https://your-n8n-instance.com` with your actual n8n URL +2. Replace `your-api-key` with your n8n API key +3. Click refresh to apply the changes + +### 4. Set Up Project Instructions + +1. Create a `.windsurfrules` file in your project root +2. Copy the Claude Project instructions from the [main README's Claude Project Setup section](../README.md#-claude-project-setup) diff --git a/docs/img/cc_command.png b/docs/img/cc_command.png new file mode 100644 index 0000000..7698167 Binary files /dev/null and b/docs/img/cc_command.png differ diff --git a/docs/img/cc_connected.png b/docs/img/cc_connected.png new file mode 100644 index 0000000..be88a35 Binary files /dev/null and b/docs/img/cc_connected.png differ diff --git a/docs/img/cursor_tut.png b/docs/img/cursor_tut.png new file mode 100644 index 0000000..9324c1b Binary files /dev/null and b/docs/img/cursor_tut.png differ diff --git a/docs/img/vsc_ghcp_chat_agent_mode.png b/docs/img/vsc_ghcp_chat_agent_mode.png new file mode 100644 index 0000000..9302d91 Binary files /dev/null and b/docs/img/vsc_ghcp_chat_agent_mode.png differ diff --git a/docs/img/vsc_ghcp_chat_instruction_files.png b/docs/img/vsc_ghcp_chat_instruction_files.png new file mode 100644 index 0000000..b7a5e6b Binary files /dev/null and b/docs/img/vsc_ghcp_chat_instruction_files.png differ diff --git a/docs/img/vsc_ghcp_chat_thinking_tool.png b/docs/img/vsc_ghcp_chat_thinking_tool.png new file mode 100644 index 0000000..4949f98 Binary files /dev/null and b/docs/img/vsc_ghcp_chat_thinking_tool.png differ diff --git a/docs/img/windsurf_tut.png b/docs/img/windsurf_tut.png new file mode 100644 index 0000000..6094788 Binary files /dev/null and b/docs/img/windsurf_tut.png differ diff --git a/package.json b/package.json index da95549..2485542 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.7.13", + "version": "2.7.16", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "bin": { @@ -41,11 +41,13 @@ "test:workflow-diff": "node dist/scripts/test-workflow-diff.js", "test:transactional-diff": "node dist/scripts/test-transactional-diff.js", "test:tools-documentation": "node dist/scripts/test-tools-documentation.js", + "test:url-configuration": "npm run build && ts-node scripts/test-url-configuration.ts", "test:search-improvements": "node dist/scripts/test-search-improvements.js", "test:fts5-search": "node dist/scripts/test-fts5-search.js", "migrate:fts5": "node dist/scripts/migrate-nodes-fts.js", "test:mcp:update-partial": "node dist/scripts/test-mcp-n8n-update-partial.js", "test:update-partial:debug": "node dist/scripts/test-update-partial-debug.js", + "test:issue-45-fix": "node dist/scripts/test-issue-45-fix.js", "test:auth-logging": "tsx scripts/test-auth-logging.ts", "sanitize:templates": "node dist/scripts/sanitize-templates.js", "db:rebuild": "node dist/scripts/rebuild-database.js", diff --git a/railway.json b/railway.json index 1e0eb02..12e3950 100644 --- a/railway.json +++ b/railway.json @@ -16,4 +16,4 @@ } ] } -} +} \ No newline at end of file diff --git a/scripts/http-bridge.js b/scripts/http-bridge.js index 6109d51..c1139ab 100755 --- a/scripts/http-bridge.js +++ b/scripts/http-bridge.js @@ -8,7 +8,10 @@ const http = require('http'); const readline = require('readline'); -const MCP_URL = process.env.MCP_URL || 'http://localhost:3000/mcp'; +// Use MCP_URL from environment or construct from HOST/PORT if available +const defaultHost = process.env.HOST || 'localhost'; +const defaultPort = process.env.PORT || '3000'; +const MCP_URL = process.env.MCP_URL || `http://${defaultHost}:${defaultPort}/mcp`; const AUTH_TOKEN = process.env.AUTH_TOKEN || process.argv[2]; if (!AUTH_TOKEN) { diff --git a/scripts/test-security.ts b/scripts/test-security.ts new file mode 100644 index 0000000..db920c9 --- /dev/null +++ b/scripts/test-security.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import axios from 'axios'; +import { spawn } from 'child_process'; + +async function testMaliciousHeaders() { + console.log('πŸ”’ Testing Security Fixes...\n'); + + // Start server with TRUST_PROXY enabled + const serverProcess = spawn('node', ['dist/mcp/index.js'], { + env: { + ...process.env, + MCP_MODE: 'http', + AUTH_TOKEN: 'test-security-token-32-characters-long', + PORT: '3999', + TRUST_PROXY: '1' + } + }); + + // Wait for server to start + await new Promise(resolve => { + serverProcess.stdout.on('data', (data) => { + if (data.toString().includes('Press Ctrl+C to stop')) { + resolve(undefined); + } + }); + }); + + const testCases = [ + { + name: 'Valid proxy headers', + headers: { + 'X-Forwarded-Host': 'example.com', + 'X-Forwarded-Proto': 'https' + } + }, + { + name: 'Malicious host header (with path)', + headers: { + 'X-Forwarded-Host': 'evil.com/path/to/evil', + 'X-Forwarded-Proto': 'https' + } + }, + { + name: 'Malicious host header (with @)', + headers: { + 'X-Forwarded-Host': 'user@evil.com', + 'X-Forwarded-Proto': 'https' + } + }, + { + name: 'Invalid hostname (multiple dots)', + headers: { + 'X-Forwarded-Host': '.....', + 'X-Forwarded-Proto': 'https' + } + }, + { + name: 'IPv6 address', + headers: { + 'X-Forwarded-Host': '[::1]:3000', + 'X-Forwarded-Proto': 'https' + } + } + ]; + + for (const testCase of testCases) { + try { + const response = await axios.get('http://localhost:3999/', { + headers: testCase.headers, + timeout: 2000 + }); + + const endpoints = response.data.endpoints; + const healthUrl = endpoints?.health?.url || 'N/A'; + + console.log(`βœ… ${testCase.name}`); + console.log(` Response: ${healthUrl}`); + + // Check if malicious headers were blocked + if (testCase.name.includes('Malicious') || testCase.name.includes('Invalid')) { + if (healthUrl.includes('evil.com') || healthUrl.includes('@') || healthUrl.includes('.....')) { + console.log(' ❌ SECURITY ISSUE: Malicious header was not blocked!'); + } else { + console.log(' βœ… Malicious header was blocked'); + } + } + } catch (error) { + console.log(`❌ ${testCase.name} - Request failed`); + } + console.log(''); + } + + serverProcess.kill(); +} + +testMaliciousHeaders().catch(console.error); \ No newline at end of file diff --git a/scripts/test-url-configuration.ts b/scripts/test-url-configuration.ts new file mode 100755 index 0000000..c0179e8 --- /dev/null +++ b/scripts/test-url-configuration.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * Test script for URL configuration in n8n-MCP HTTP server + * Tests various BASE_URL, TRUST_PROXY, and proxy header scenarios + */ + +import axios from 'axios'; +import { spawn } from 'child_process'; +import { logger } from '../src/utils/logger'; + +interface TestCase { + name: string; + env: Record; + expectedUrls?: { + health: string; + mcp: string; + }; + proxyHeaders?: Record; +} + +const testCases: TestCase[] = [ + { + name: 'Default configuration (no BASE_URL)', + env: { + MCP_MODE: 'http', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3001' + }, + expectedUrls: { + health: 'http://localhost:3001/health', + mcp: 'http://localhost:3001/mcp' + } + }, + { + name: 'With BASE_URL configured', + env: { + MCP_MODE: 'http', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3002', + BASE_URL: 'https://n8n-mcp.example.com' + }, + expectedUrls: { + health: 'https://n8n-mcp.example.com/health', + mcp: 'https://n8n-mcp.example.com/mcp' + } + }, + { + name: 'With PUBLIC_URL configured', + env: { + MCP_MODE: 'http', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3003', + PUBLIC_URL: 'https://api.company.com/mcp' + }, + expectedUrls: { + health: 'https://api.company.com/mcp/health', + mcp: 'https://api.company.com/mcp/mcp' + } + }, + { + name: 'With TRUST_PROXY and proxy headers', + env: { + MCP_MODE: 'http', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3004', + TRUST_PROXY: '1' + }, + proxyHeaders: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'proxy.example.com' + } + }, + { + name: 'Fixed HTTP implementation', + env: { + MCP_MODE: 'http', + USE_FIXED_HTTP: 'true', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3005', + BASE_URL: 'https://fixed.example.com' + }, + expectedUrls: { + health: 'https://fixed.example.com/health', + mcp: 'https://fixed.example.com/mcp' + } + } +]; + +async function runTest(testCase: TestCase): Promise { + console.log(`\nπŸ§ͺ Testing: ${testCase.name}`); + console.log('Environment:', testCase.env); + + const serverProcess = spawn('node', ['dist/mcp/index.js'], { + env: { ...process.env, ...testCase.env } + }); + + let serverOutput = ''; + let serverStarted = false; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + serverProcess.kill(); + reject(new Error('Server startup timeout')); + }, 10000); + + serverProcess.stdout.on('data', (data) => { + const output = data.toString(); + serverOutput += output; + + if (output.includes('Press Ctrl+C to stop the server')) { + serverStarted = true; + clearTimeout(timeout); + + // Give server a moment to fully initialize + setTimeout(async () => { + try { + // Test root endpoint + const rootUrl = `http://localhost:${testCase.env.PORT}/`; + const rootResponse = await axios.get(rootUrl, { + headers: testCase.proxyHeaders || {} + }); + + console.log('βœ… Root endpoint response:'); + console.log(` - Endpoints: ${JSON.stringify(rootResponse.data.endpoints, null, 2)}`); + + // Test health endpoint + const healthUrl = `http://localhost:${testCase.env.PORT}/health`; + const healthResponse = await axios.get(healthUrl); + console.log(`βœ… Health endpoint status: ${healthResponse.data.status}`); + + // Test MCP info endpoint + const mcpUrl = `http://localhost:${testCase.env.PORT}/mcp`; + const mcpResponse = await axios.get(mcpUrl); + console.log(`βœ… MCP info endpoint: ${mcpResponse.data.description}`); + + // Check console output + if (testCase.expectedUrls) { + const outputContainsExpectedUrls = + serverOutput.includes(testCase.expectedUrls.health) && + serverOutput.includes(testCase.expectedUrls.mcp); + + if (outputContainsExpectedUrls) { + console.log('βœ… Console output shows expected URLs'); + } else { + console.log('❌ Console output does not show expected URLs'); + console.log('Expected:', testCase.expectedUrls); + } + } + + serverProcess.kill(); + resolve(); + } catch (error) { + console.error('❌ Test failed:', error instanceof Error ? error.message : String(error)); + serverProcess.kill(); + reject(error); + } + }, 500); + } + }); + + serverProcess.stderr.on('data', (data) => { + console.error('Server error:', data.toString()); + }); + + serverProcess.on('close', (code) => { + if (!serverStarted) { + reject(new Error(`Server exited with code ${code} before starting`)); + } else { + resolve(); + } + }); + }); +} + +async function main() { + console.log('πŸš€ n8n-MCP URL Configuration Test Suite'); + console.log('======================================'); + + for (const testCase of testCases) { + try { + await runTest(testCase); + console.log('βœ… Test passed\n'); + } catch (error) { + console.error('❌ Test failed:', error instanceof Error ? error.message : String(error)); + console.log('\n'); + } + } + + console.log('✨ All tests completed'); +} + +main().catch(console.error); \ No newline at end of file diff --git a/src/http-server-single-session.ts b/src/http-server-single-session.ts index cd5d675..41069b6 100644 --- a/src/http-server-single-session.ts +++ b/src/http-server-single-session.ts @@ -11,6 +11,8 @@ import { ConsoleManager } from './utils/console-manager'; import { logger } from './utils/logger'; import { readFileSync } from 'fs'; import dotenv from 'dotenv'; +import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector'; +import { PROJECT_VERSION } from './utils/version'; dotenv.config(); @@ -244,12 +246,44 @@ export class SingleSessionHTTPServer { next(); }); + // 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 ', + required_for: ['POST /mcp'] + }, + documentation: 'https://github.com/czlonkowski/n8n-mcp' + }); + }); + // Health check endpoint (no body parsing needed for GET) app.get('/health', (req, res) => { res.json({ status: 'ok', mode: 'single-session', - version: '2.3.2', + version: PROJECT_VERSION, uptime: Math.floor(process.uptime()), sessionActive: !!this.session, sessionAge: this.session @@ -264,6 +298,35 @@ export class SingleSessionHTTPServer { }); }); + // 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 with authentication app.post('/mcp', async (req: express.Request, res: express.Response): Promise => { // Enhanced authentication check with specific logging @@ -361,9 +424,14 @@ export class SingleSessionHTTPServer { this.expressServer = app.listen(port, host, () => { logger.info(`n8n MCP Single-Session 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 Single-Session HTTP Server running on ${host}:${port}`); - console.log(`Health check: http://localhost:${port}/health`); - console.log(`MCP endpoint: http://localhost:${port}/mcp`); + 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 @@ -375,6 +443,12 @@ export class SingleSessionHTTPServer { } }, 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 server errors diff --git a/src/http-server.ts b/src/http-server.ts index 04f4fa2..3d90bf8 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -13,6 +13,7 @@ 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'; dotenv.config(); @@ -159,6 +160,38 @@ export async function startFixedHTTPServer() { 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 ', + required_for: ['POST /mcp'] + }, + documentation: 'https://github.com/czlonkowski/n8n-mcp' + }); + }); + // Health check endpoint app.get('/health', (req, res) => { res.json({ @@ -195,6 +228,35 @@ export async function startFixedHTTPServer() { } }); + // 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 => { const startTime = Date.now(); @@ -428,9 +490,14 @@ export async function startFixedHTTPServer() { 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: http://localhost:${port}/health`); - console.log(`MCP endpoint: http://localhost:${port}/mcp`); + 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 @@ -442,6 +509,12 @@ export async function startFixedHTTPServer() { } }, 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 diff --git a/src/scripts/rebuild.ts b/src/scripts/rebuild.ts index f4629bf..8ac6e73 100644 --- a/src/scripts/rebuild.ts +++ b/src/scripts/rebuild.ts @@ -15,7 +15,8 @@ import * as path from 'path'; async function rebuild() { console.log('πŸ”„ Rebuilding n8n node database...\n'); - const db = await createDatabaseAdapter('./data/nodes.db'); + const dbPath = process.env.NODE_DB_PATH || './data/nodes.db'; + const db = await createDatabaseAdapter(dbPath); const loader = new N8nNodeLoader(); const parser = new NodeParser(); const mapper = new DocsMapper(); diff --git a/src/scripts/test-issue-45-fix.ts b/src/scripts/test-issue-45-fix.ts new file mode 100644 index 0000000..0d24e4c --- /dev/null +++ b/src/scripts/test-issue-45-fix.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/** + * Test for Issue #45 Fix: Partial Update Tool Validation/Execution Discrepancy + * + * This test verifies that the cleanWorkflowForUpdate function no longer adds + * default settings to workflows during updates, which was causing the n8n API + * to reject requests with "settings must NOT have additional properties". + */ + +import { config } from 'dotenv'; +import { logger } from '../utils/logger'; +import { cleanWorkflowForUpdate, cleanWorkflowForCreate } from '../services/n8n-validation'; +import { Workflow } from '../types/n8n-api'; + +// Load environment variables +config(); + +function testCleanWorkflowFunctions() { + logger.info('Testing Issue #45 Fix: cleanWorkflowForUpdate should not add default settings\n'); + + // Test 1: cleanWorkflowForUpdate with workflow without settings + logger.info('=== Test 1: cleanWorkflowForUpdate without settings ==='); + const workflowWithoutSettings: Workflow = { + id: 'test-123', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + versionId: 'version-123' + }; + + const cleanedUpdate = cleanWorkflowForUpdate(workflowWithoutSettings); + + if ('settings' in cleanedUpdate) { + logger.error('❌ FAIL: cleanWorkflowForUpdate added settings when it should not have'); + logger.error(' Found settings:', JSON.stringify(cleanedUpdate.settings)); + } else { + logger.info('βœ… PASS: cleanWorkflowForUpdate did not add settings'); + } + + // Test 2: cleanWorkflowForUpdate with existing settings + logger.info('\n=== Test 2: cleanWorkflowForUpdate with existing settings ==='); + const workflowWithSettings: Workflow = { + ...workflowWithoutSettings, + settings: { + executionOrder: 'v1', + saveDataErrorExecution: 'none', + saveDataSuccessExecution: 'none', + saveManualExecutions: false, + saveExecutionProgress: false + } + }; + + const cleanedUpdate2 = cleanWorkflowForUpdate(workflowWithSettings); + + if ('settings' in cleanedUpdate2) { + const settingsMatch = JSON.stringify(cleanedUpdate2.settings) === JSON.stringify(workflowWithSettings.settings); + if (settingsMatch) { + logger.info('βœ… PASS: cleanWorkflowForUpdate preserved existing settings without modification'); + } else { + logger.error('❌ FAIL: cleanWorkflowForUpdate modified existing settings'); + logger.error(' Original:', JSON.stringify(workflowWithSettings.settings)); + logger.error(' Cleaned:', JSON.stringify(cleanedUpdate2.settings)); + } + } else { + logger.error('❌ FAIL: cleanWorkflowForUpdate removed existing settings'); + } + + // Test 3: cleanWorkflowForUpdate with partial settings + logger.info('\n=== Test 3: cleanWorkflowForUpdate with partial settings ==='); + const workflowWithPartialSettings: Workflow = { + ...workflowWithoutSettings, + settings: { + executionOrder: 'v1' + // Missing other default properties + } + }; + + const cleanedUpdate3 = cleanWorkflowForUpdate(workflowWithPartialSettings); + + if ('settings' in cleanedUpdate3) { + const settingsKeys = cleanedUpdate3.settings ? Object.keys(cleanedUpdate3.settings) : []; + const hasOnlyExecutionOrder = settingsKeys.length === 1 && + cleanedUpdate3.settings?.executionOrder === 'v1'; + if (hasOnlyExecutionOrder) { + logger.info('βœ… PASS: cleanWorkflowForUpdate preserved partial settings without adding defaults'); + } else { + logger.error('❌ FAIL: cleanWorkflowForUpdate added default properties to partial settings'); + logger.error(' Original keys:', Object.keys(workflowWithPartialSettings.settings || {})); + logger.error(' Cleaned keys:', settingsKeys); + } + } else { + logger.error('❌ FAIL: cleanWorkflowForUpdate removed partial settings'); + } + + // Test 4: Verify cleanWorkflowForCreate still adds defaults + logger.info('\n=== Test 4: cleanWorkflowForCreate should add default settings ==='); + const newWorkflow = { + name: 'New Workflow', + nodes: [], + connections: {} + }; + + const cleanedCreate = cleanWorkflowForCreate(newWorkflow); + + if ('settings' in cleanedCreate && cleanedCreate.settings) { + const hasDefaults = + cleanedCreate.settings.executionOrder === 'v1' && + cleanedCreate.settings.saveDataErrorExecution === 'all' && + cleanedCreate.settings.saveDataSuccessExecution === 'all' && + cleanedCreate.settings.saveManualExecutions === true && + cleanedCreate.settings.saveExecutionProgress === true; + + if (hasDefaults) { + logger.info('βœ… PASS: cleanWorkflowForCreate correctly adds default settings'); + } else { + logger.error('❌ FAIL: cleanWorkflowForCreate added settings but not with correct defaults'); + logger.error(' Settings:', JSON.stringify(cleanedCreate.settings)); + } + } else { + logger.error('❌ FAIL: cleanWorkflowForCreate did not add default settings'); + } + + // Test 5: Verify read-only fields are removed + logger.info('\n=== Test 5: cleanWorkflowForUpdate removes read-only fields ==='); + const workflowWithReadOnly: any = { + ...workflowWithoutSettings, + staticData: { some: 'data' }, + pinData: { node1: 'data' }, + tags: ['tag1', 'tag2'], + isArchived: true, + usedCredentials: ['cred1'], + sharedWithProjects: ['proj1'], + triggerCount: 5, + shared: true, + active: true + }; + + const cleanedReadOnly = cleanWorkflowForUpdate(workflowWithReadOnly); + + const removedFields = [ + 'id', 'createdAt', 'updatedAt', 'versionId', 'meta', + 'staticData', 'pinData', 'tags', 'isArchived', + 'usedCredentials', 'sharedWithProjects', 'triggerCount', + 'shared', 'active' + ]; + + const hasRemovedFields = removedFields.some(field => field in cleanedReadOnly); + + if (!hasRemovedFields) { + logger.info('βœ… PASS: cleanWorkflowForUpdate correctly removed all read-only fields'); + } else { + const foundFields = removedFields.filter(field => field in cleanedReadOnly); + logger.error('❌ FAIL: cleanWorkflowForUpdate did not remove these fields:', foundFields); + } + + logger.info('\n=== Test Summary ==='); + logger.info('All tests completed. The fix ensures that cleanWorkflowForUpdate only removes fields'); + logger.info('without adding default settings, preventing the n8n API validation error.'); +} + +// Run the tests +testCleanWorkflowFunctions(); \ No newline at end of file diff --git a/src/services/n8n-validation.ts b/src/services/n8n-validation.ts index 814e61e..b699509 100644 --- a/src/services/n8n-validation.ts +++ b/src/services/n8n-validation.ts @@ -93,6 +93,19 @@ export function cleanWorkflowForCreate(workflow: Partial): Partial { const { // Remove read-only/computed fields @@ -116,11 +129,6 @@ export function cleanWorkflowForUpdate(workflow: Workflow): Partial { ...cleanedWorkflow } = workflow as any; - // Ensure settings are present - if (!cleanedWorkflow.settings) { - cleanedWorkflow.settings = defaultWorkflowSettings; - } - return cleanedWorkflow; } diff --git a/src/utils/url-detector.ts b/src/utils/url-detector.ts new file mode 100644 index 0000000..30e7c28 --- /dev/null +++ b/src/utils/url-detector.ts @@ -0,0 +1,111 @@ +import { Request } from 'express'; +import { logger } from './logger'; + +/** + * Validates a hostname to prevent header injection attacks + */ +function isValidHostname(host: string): boolean { + // Allow alphanumeric, dots, hyphens, and optional port + return /^[a-zA-Z0-9.-]+(:[0-9]+)?$/.test(host) && host.length < 256; +} + +/** + * Validates a URL string + */ +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + // Only allow http and https protocols + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Detects the base URL for the server, considering: + * 1. Explicitly configured BASE_URL or PUBLIC_URL + * 2. Proxy headers (X-Forwarded-Proto, X-Forwarded-Host) + * 3. Host and port configuration + */ +export function detectBaseUrl(req: Request | null, host: string, port: number): string { + try { + // 1. Check for explicitly configured URL + const configuredUrl = process.env.BASE_URL || process.env.PUBLIC_URL; + if (configuredUrl) { + if (isValidUrl(configuredUrl)) { + logger.debug('Using configured BASE_URL/PUBLIC_URL', { url: configuredUrl }); + return configuredUrl.replace(/\/$/, ''); // Remove trailing slash + } else { + logger.warn('Invalid BASE_URL/PUBLIC_URL configured, falling back to auto-detection', { url: configuredUrl }); + } + } + + // 2. If we have a request, try to detect from proxy headers + if (req && process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) { + const proto = req.get('X-Forwarded-Proto') || req.protocol || 'http'; + const forwardedHost = req.get('X-Forwarded-Host'); + const hostHeader = req.get('Host'); + + const detectedHost = forwardedHost || hostHeader; + if (detectedHost && isValidHostname(detectedHost)) { + const baseUrl = `${proto}://${detectedHost}`; + logger.debug('Detected URL from proxy headers', { + proto, + forwardedHost, + hostHeader, + baseUrl + }); + return baseUrl; + } else if (detectedHost) { + logger.warn('Invalid hostname detected in proxy headers, using fallback', { detectedHost }); + } + } + + // 3. Fall back to configured host and port + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + const protocol = 'http'; // Default to http for local bindings + + // Don't show standard ports (for http only in this fallback case) + const needsPort = port !== 80; + const baseUrl = needsPort ? + `${protocol}://${displayHost}:${port}` : + `${protocol}://${displayHost}`; + + logger.debug('Using fallback URL from host/port', { + host, + displayHost, + port, + baseUrl + }); + + return baseUrl; + } catch (error) { + logger.error('Error detecting base URL, using fallback', error); + // Safe fallback + return `http://localhost:${port}`; + } +} + +/** + * Gets the base URL for console display during startup + * This is used when we don't have a request object yet + */ +export function getStartupBaseUrl(host: string, port: number): string { + return detectBaseUrl(null, host, port); +} + +/** + * Formats endpoint URLs for display + */ +export function formatEndpointUrls(baseUrl: string): { + health: string; + mcp: string; + root: string; +} { + return { + health: `${baseUrl}/health`, + mcp: `${baseUrl}/mcp`, + root: baseUrl + }; +} \ No newline at end of file