From 2a5c4ec6ebc576a83840c93af18c3d51e4c39a1b Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Sun, 6 Jul 2025 18:32:15 +0200 Subject: [PATCH] feat: add AUTH_TOKEN_FILE support for Docker secrets (v2.7.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AUTH_TOKEN_FILE environment variable support for reading auth tokens from files - Support Docker secrets pattern for production deployments - Add Known Issues section documenting Claude Desktop container duplication bug - Update documentation with authentication options and best practices - Fix issue #16: AUTH_TOKEN_FILE was documented but not implemented - Add comprehensive tests for AUTH_TOKEN_FILE functionality BREAKING CHANGE: None - AUTH_TOKEN continues to work as before 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 10 +- README.md | 36 +++++ docs/CHANGELOG.md | 15 ++ docs/DOCKER_README.md | 37 ++++- package.json | 2 +- src/http-server.ts | 44 +++++- tests/http-server-auth.test.ts | 266 +++++++++++++++++++++++++++++++++ 7 files changed, 395 insertions(+), 15 deletions(-) create mode 100644 tests/http-server-auth.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index e90577c..ff6f9a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co n8n-mcp is a comprehensive documentation and knowledge server that provides AI assistants with complete access to n8n node information through the Model Context Protocol (MCP). It serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively. -## ✅ Latest Updates (v2.7.4) +## ✅ Latest Updates (v2.7.5) + +### Update (v2.7.5) - AUTH_TOKEN_FILE Support & Known Issues: +- ✅ **NEW: AUTH_TOKEN_FILE support** - Read authentication token from file (Docker secrets compatible) +- ✅ **ADDED: Known Issues section** - Documented Claude Desktop container duplication bug +- ✅ **ENHANCED: Authentication flexibility** - Support both AUTH_TOKEN and AUTH_TOKEN_FILE variables +- ✅ **FIXED: Issue #16** - AUTH_TOKEN_FILE now properly implemented as documented +- ✅ **DOCKER SECRETS**: Seamlessly integrate with Docker secrets management +- ✅ **BACKWARD COMPATIBLE**: AUTH_TOKEN continues to work as before ### Update (v2.7.4) - Self-Documenting MCP Tools: - ✅ **RENAMED: start_here_workflow_guide → tools_documentation** - More descriptive name diff --git a/README.md b/README.md index 0fb225d..acbb64a 100644 --- a/README.md +++ b/README.md @@ -546,6 +546,42 @@ Current database coverage (n8n v1.99.1): See [CHANGELOG.md](./docs/CHANGELOG.md) for full version history. +## ⚠️ Known Issues + +### Claude Desktop Container Duplication +When using n8n-MCP with Claude Desktop in Docker mode, Claude Desktop may start the container twice during initialization. This is a known Claude Desktop bug ([modelcontextprotocol/servers#812](https://github.com/modelcontextprotocol/servers/issues/812)). + +**Symptoms:** +- Two identical containers running for the same MCP server +- Container name conflicts if using `--name` parameter +- Doubled resource usage + +**Workarounds:** +1. **Avoid using --name parameter** - Let Docker assign random names: +```json +{ + "mcpServers": { + "n8n-mcp": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "ghcr.io/czlonkowski/n8n-mcp:latest" + ] + } + } +} +``` + +2. **Use HTTP mode instead** - Deploy n8n-mcp as a standalone HTTP server: +```bash +docker compose up -d # Start HTTP server +``` +Then connect via mcp-remote (see [HTTP Deployment Guide](./docs/HTTP_DEPLOYMENT.md)) + +3. **Use Docker MCP Toolkit** - Better container management through Docker Desktop + +This issue does not affect the functionality of n8n-MCP itself, only the container management in Claude Desktop. + ## 📦 License MIT License - see [LICENSE](LICENSE) for details. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 529fe2c..3fb5ed7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,21 @@ 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.5] - 2025-07-06 + +### Added +- AUTH_TOKEN_FILE support for reading authentication tokens from files (Docker secrets compatible) +- Known Issues section in README documenting Claude Desktop container duplication bug +- Enhanced authentication documentation in Docker README + +### Fixed +- Issue #16: AUTH_TOKEN_FILE was documented but not implemented +- HTTP server now properly supports both AUTH_TOKEN and AUTH_TOKEN_FILE environment variables + +### Changed +- Authentication logic now checks AUTH_TOKEN first, then falls back to AUTH_TOKEN_FILE +- Updated Docker documentation to clarify authentication options + ## [2.7.4] - 2025-07-03 ### Changed diff --git a/docs/DOCKER_README.md b/docs/DOCKER_README.md index 12db288..d773579 100644 --- a/docs/DOCKER_README.md +++ b/docs/DOCKER_README.md @@ -59,11 +59,14 @@ docker run -d \ | Variable | Description | Default | Required | |----------|-------------|---------|----------| | `MCP_MODE` | Server mode: `stdio` or `http` | `stdio` | No | -| `AUTH_TOKEN` | Bearer token for HTTP authentication | - | Yes (HTTP mode) | +| `AUTH_TOKEN` | Bearer token for HTTP authentication | - | Yes (HTTP mode)* | +| `AUTH_TOKEN_FILE` | Path to file containing auth token (v2.7.5+) | - | Yes (HTTP mode)* | | `PORT` | HTTP server port | `3000` | No | | `NODE_ENV` | Environment: `development` or `production` | `production` | No | | `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, `error` | `info` | No | +*Either `AUTH_TOKEN` or `AUTH_TOKEN_FILE` must be set for HTTP mode. If both are set, `AUTH_TOKEN` takes precedence. + ### Docker Compose Configuration The default `docker-compose.yml` provides: @@ -238,18 +241,40 @@ docker inspect n8n-mcp | jq '.[0].State.Health' ### Authentication -- Always use a strong AUTH_TOKEN (minimum 32 characters) -- Never commit tokens to version control -- Rotate tokens regularly +n8n-MCP supports two authentication methods for HTTP mode: + +#### Method 1: AUTH_TOKEN (Environment Variable) +- Set the token directly as an environment variable +- Simple and straightforward for basic deployments +- Always use a strong token (minimum 32 characters) ```bash # Generate secure token openssl rand -base64 32 -# Or use uuidgen -uuidgen | tr -d '-' | base64 +# Use in Docker +docker run -e AUTH_TOKEN=your-secure-token ... ``` +#### Method 2: AUTH_TOKEN_FILE (File Path) - NEW in v2.7.5 +- Read token from a file (Docker secrets compatible) +- More secure for production deployments +- Prevents token exposure in process lists + +```bash +# Create token file +echo "your-secure-token" > /path/to/token.txt + +# Use with Docker secrets +docker run -e AUTH_TOKEN_FILE=/run/secrets/auth_token ... +``` + +#### Best Practices +- Never commit tokens to version control +- Rotate tokens regularly +- Use AUTH_TOKEN_FILE with Docker secrets for production +- Ensure token files have restricted permissions (600) + ### Network Security For production deployments: diff --git a/package.json b/package.json index 5921a83..5254740 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.7.4", + "version": "2.7.5", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "scripts": { diff --git a/src/http-server.ts b/src/http-server.ts index a151603..021c1bc 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -12,26 +12,56 @@ import { logger } from './utils/logger'; import { PROJECT_VERSION } from './utils/version'; import { isN8nApiConfigured } from './config/n8n-api'; import dotenv from 'dotenv'; +import { readFileSync } from 'fs'; dotenv.config(); let expressServer: any; +let authToken: string | null = null; + +/** + * Load auth token from environment variable or file + */ +export function loadAuthToken(): string | null { + // First, try AUTH_TOKEN environment variable + if (process.env.AUTH_TOKEN) { + logger.info('Using AUTH_TOKEN from environment variable'); + return process.env.AUTH_TOKEN; + } + + // Then, try AUTH_TOKEN_FILE + if (process.env.AUTH_TOKEN_FILE) { + try { + const token = readFileSync(process.env.AUTH_TOKEN_FILE, 'utf-8').trim(); + logger.info(`Loaded AUTH_TOKEN from file: ${process.env.AUTH_TOKEN_FILE}`); + return token; + } catch (error) { + logger.error(`Failed to read AUTH_TOKEN_FILE: ${process.env.AUTH_TOKEN_FILE}`, error); + console.error(`ERROR: Failed to read AUTH_TOKEN_FILE: ${process.env.AUTH_TOKEN_FILE}`); + console.error(error instanceof Error ? error.message : 'Unknown error'); + return null; + } + } + + return null; +} /** * Validate required environment variables */ function validateEnvironment() { - const required = ['AUTH_TOKEN']; - const missing = required.filter(key => !process.env[key]); + // Load auth token from env var or file + authToken = loadAuthToken(); - if (missing.length > 0) { - logger.error(`Missing required environment variables: ${missing.join(', ')}`); - console.error(`ERROR: Missing required environment variables: ${missing.join(', ')}`); + if (!authToken) { + logger.error('No authentication token found'); + console.error('ERROR: AUTH_TOKEN is required for HTTP mode'); + console.error('Set AUTH_TOKEN environment variable or AUTH_TOKEN_FILE pointing to a file containing the token'); console.error('Generate AUTH_TOKEN with: openssl rand -base64 32'); process.exit(1); } - if (process.env.AUTH_TOKEN && process.env.AUTH_TOKEN.length < 32) { + if (authToken.length < 32) { logger.warn('AUTH_TOKEN should be at least 32 characters for security'); console.warn('WARNING: AUTH_TOKEN should be at least 32 characters for security'); } @@ -151,7 +181,7 @@ export async function startFixedHTTPServer() { ? authHeader.slice(7) : authHeader; - if (token !== process.env.AUTH_TOKEN) { + if (token !== authToken) { logger.warn('Authentication failed', { ip: req.ip, userAgent: req.get('user-agent') diff --git a/tests/http-server-auth.test.ts b/tests/http-server-auth.test.ts new file mode 100644 index 0000000..f07c5a3 --- /dev/null +++ b/tests/http-server-auth.test.ts @@ -0,0 +1,266 @@ +import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +// Mock dependencies +jest.mock('../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() + }, + Logger: jest.fn().mockImplementation(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() + })), + LogLevel: { + ERROR: 0, + WARN: 1, + INFO: 2, + DEBUG: 3 + } +})); + +jest.mock('dotenv'); + +// Mock other dependencies to prevent side effects +jest.mock('../src/mcp/server', () => ({ + N8NDocumentationMCPServer: jest.fn().mockImplementation(() => ({ + executeTool: jest.fn() + })) +})); + +jest.mock('../src/mcp/tools', () => ({ + n8nDocumentationToolsFinal: [] +})); + +jest.mock('../src/mcp/tools-n8n-manager', () => ({ + n8nManagementTools: [] +})); + +jest.mock('../src/utils/version', () => ({ + PROJECT_VERSION: '2.7.4' +})); + +jest.mock('../src/config/n8n-api', () => ({ + isN8nApiConfigured: jest.fn().mockReturnValue(false) +})); + +// Mock Express to prevent server from starting +jest.mock('express', () => { + const mockApp = { + use: jest.fn(), + get: jest.fn(), + post: jest.fn(), + listen: jest.fn().mockReturnValue({ + on: jest.fn() + }) + }; + const express: any = jest.fn(() => mockApp); + express.json = jest.fn(); + express.urlencoded = jest.fn(); + express.static = jest.fn(); + express.Request = {}; + express.Response = {}; + express.NextFunction = {}; + return express; +}); + +describe('HTTP Server Authentication', () => { + const originalEnv = process.env; + let tempDir: string; + let authTokenFile: string; + + beforeEach(() => { + // Reset modules and environment + jest.resetModules(); + process.env = { ...originalEnv }; + + // Create temporary directory for test files + tempDir = join(tmpdir(), `http-server-auth-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + authTokenFile = join(tempDir, 'auth-token'); + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + + // Clean up temporary directory + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe('loadAuthToken', () => { + let loadAuthToken: () => string | null; + + beforeEach(() => { + // Import the function after environment is set up + const httpServerModule = require('../src/http-server'); + // Access the loadAuthToken function (we'll need to export it) + loadAuthToken = httpServerModule.loadAuthToken || (() => null); + }); + + it('should load token from AUTH_TOKEN environment variable', () => { + process.env.AUTH_TOKEN = 'test-token-from-env'; + delete process.env.AUTH_TOKEN_FILE; + + // Re-import to get fresh module with new env + jest.resetModules(); + const { loadAuthToken } = require('../src/http-server'); + + const token = loadAuthToken(); + expect(token).toBe('test-token-from-env'); + }); + + it('should load token from AUTH_TOKEN_FILE when AUTH_TOKEN is not set', () => { + delete process.env.AUTH_TOKEN; + process.env.AUTH_TOKEN_FILE = authTokenFile; + + // Write test token to file + writeFileSync(authTokenFile, 'test-token-from-file\n'); + + // Re-import to get fresh module with new env + jest.resetModules(); + const { loadAuthToken } = require('../src/http-server'); + + const token = loadAuthToken(); + expect(token).toBe('test-token-from-file'); + }); + + it('should trim whitespace from token file', () => { + delete process.env.AUTH_TOKEN; + process.env.AUTH_TOKEN_FILE = authTokenFile; + + // Write token with whitespace + writeFileSync(authTokenFile, ' test-token-with-spaces \n\n'); + + jest.resetModules(); + const { loadAuthToken } = require('../src/http-server'); + + const token = loadAuthToken(); + expect(token).toBe('test-token-with-spaces'); + }); + + it('should prefer AUTH_TOKEN over AUTH_TOKEN_FILE', () => { + process.env.AUTH_TOKEN = 'env-token'; + process.env.AUTH_TOKEN_FILE = authTokenFile; + writeFileSync(authTokenFile, 'file-token'); + + jest.resetModules(); + const { loadAuthToken } = require('../src/http-server'); + + const token = loadAuthToken(); + expect(token).toBe('env-token'); + }); + + it('should return null when AUTH_TOKEN_FILE points to non-existent file', () => { + delete process.env.AUTH_TOKEN; + process.env.AUTH_TOKEN_FILE = join(tempDir, 'non-existent-file'); + + jest.resetModules(); + const { loadAuthToken } = require('../src/http-server'); + const { logger } = require('../src/utils/logger'); + + const token = loadAuthToken(); + expect(token).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + const errorCall = logger.error.mock.calls[0]; + expect(errorCall[0]).toContain('Failed to read AUTH_TOKEN_FILE'); + expect(errorCall[1]).toBeInstanceOf(Error); + }); + + it('should return null when neither AUTH_TOKEN nor AUTH_TOKEN_FILE is set', () => { + delete process.env.AUTH_TOKEN; + delete process.env.AUTH_TOKEN_FILE; + + jest.resetModules(); + const { loadAuthToken } = require('../src/http-server'); + + const token = loadAuthToken(); + expect(token).toBeNull(); + }); + }); + + describe('validateEnvironment', () => { + it('should exit when no auth token is available', () => { + delete process.env.AUTH_TOKEN; + delete process.env.AUTH_TOKEN_FILE; + + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('Process exited'); + }); + + jest.resetModules(); + + expect(() => { + require('../src/http-server'); + }).toThrow('Process exited'); + + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + }); + + it('should warn when token is less than 32 characters', () => { + process.env.AUTH_TOKEN = 'short-token'; + + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('Process exited'); + }); + + jest.resetModules(); + const { logger } = require('../src/utils/logger'); + + try { + require('../src/http-server'); + } catch (error) { + // Module loads but may fail on server start + } + + expect(logger.warn).toHaveBeenCalledWith( + 'AUTH_TOKEN should be at least 32 characters for security' + ); + + mockExit.mockRestore(); + }); + }); + + describe('Integration test scenarios', () => { + it('should successfully authenticate with token from file', () => { + // This is more of an integration test placeholder + // In a real scenario, you'd start the server and make HTTP requests + + writeFileSync(authTokenFile, 'very-secure-token-with-more-than-32-characters'); + process.env.AUTH_TOKEN_FILE = authTokenFile; + delete process.env.AUTH_TOKEN; + + jest.resetModules(); + const { loadAuthToken } = require('../src/http-server'); + + const token = loadAuthToken(); + expect(token).toBe('very-secure-token-with-more-than-32-characters'); + }); + + it('should handle Docker secrets pattern', () => { + // Docker secrets are typically mounted at /run/secrets/ + const dockerSecretPath = join(tempDir, 'run', 'secrets', 'auth_token'); + mkdirSync(join(tempDir, 'run', 'secrets'), { recursive: true }); + writeFileSync(dockerSecretPath, 'docker-secret-token'); + + process.env.AUTH_TOKEN_FILE = dockerSecretPath; + delete process.env.AUTH_TOKEN; + + jest.resetModules(); + const { loadAuthToken } = require('../src/http-server'); + + const token = loadAuthToken(); + expect(token).toBe('docker-secret-token'); + }); + }); +}); \ No newline at end of file