Skip to main content
Glama

n8n-MCP

by 88-888
docker-entrypoint.test.tsโ€ข22.4 kB
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; import { exec, waitForHealthy, isRunningInHttpMode, getProcessEnv } from './test-helpers'; // Skip tests if not in CI or if Docker is not available const SKIP_DOCKER_TESTS = process.env.CI !== 'true' && !process.env.RUN_DOCKER_TESTS; const describeDocker = SKIP_DOCKER_TESTS ? describe.skip : describe; // Helper to check if Docker is available async function isDockerAvailable(): Promise<boolean> { try { await exec('docker --version'); return true; } catch { return false; } } // Helper to generate unique container names function generateContainerName(suffix: string): string { return `n8n-mcp-entrypoint-test-${Date.now()}-${suffix}`; } // Helper to clean up containers async function cleanupContainer(containerName: string) { try { await exec(`docker stop ${containerName}`); await exec(`docker rm ${containerName}`); } catch { // Ignore errors - container might not exist } } // Helper to run container with timeout async function runContainerWithTimeout( containerName: string, dockerCmd: string, timeoutMs: number = 5000 ): Promise<{ stdout: string; stderr: string }> { return new Promise(async (resolve, reject) => { const timeout = setTimeout(async () => { try { await exec(`docker stop ${containerName}`); } catch {} reject(new Error(`Container timeout after ${timeoutMs}ms`)); }, timeoutMs); try { const result = await exec(dockerCmd); clearTimeout(timeout); resolve(result); } catch (error) { clearTimeout(timeout); reject(error); } }); } describeDocker('Docker Entrypoint Script', () => { let tempDir: string; let dockerAvailable: boolean; const imageName = 'n8n-mcp-test:latest'; const containers: string[] = []; beforeAll(async () => { dockerAvailable = await isDockerAvailable(); if (!dockerAvailable) { console.warn('Docker not available, skipping Docker entrypoint tests'); return; } // Check if image exists let imageExists = false; try { await exec(`docker image inspect ${imageName}`); imageExists = true; } catch { imageExists = false; } // Build test image if in CI or if explicitly requested or if image doesn't exist if (!imageExists || process.env.CI === 'true' || process.env.BUILD_DOCKER_TEST_IMAGE === 'true') { const projectRoot = path.resolve(__dirname, '../../../'); console.log('Building Docker image for tests...'); try { execSync(`docker build -t ${imageName} .`, { cwd: projectRoot, stdio: 'inherit' }); console.log('Docker image built successfully'); } catch (error) { console.error('Failed to build Docker image:', error); throw new Error('Docker image build failed - tests cannot continue'); } } else { console.log(`Using existing Docker image: ${imageName}`); } }, 60000); // Increase timeout to 60s for Docker build beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-entrypoint-test-')); }); afterEach(async () => { // Clean up containers with error tracking const cleanupErrors: string[] = []; for (const container of containers) { try { await cleanupContainer(container); } catch (error) { cleanupErrors.push(`Failed to cleanup ${container}: ${error}`); } } if (cleanupErrors.length > 0) { console.warn('Container cleanup errors:', cleanupErrors); } containers.length = 0; // Clean up temp directory if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); } }, 20000); // Increase timeout for cleanup describe('MCP Mode handling', () => { it('should default to stdio mode when MCP_MODE is not set', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('default-mode'); containers.push(containerName); // Check that stdio mode is used by default const { stdout } = await exec( `docker run --name ${containerName} ${imageName} sh -c "env | grep -E '^MCP_MODE=' || echo 'MCP_MODE not set (defaults to stdio)'"` ); // Should either show MCP_MODE=stdio or indicate it's not set (which means stdio by default) expect(stdout.trim()).toMatch(/MCP_MODE=stdio|MCP_MODE not set/); }); it('should respect MCP_MODE=http environment variable', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('http-mode'); containers.push(containerName); // Run in HTTP mode const { stdout } = await exec( `docker run --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN=test ${imageName} sh -c "env | grep MCP_MODE"` ); expect(stdout.trim()).toBe('MCP_MODE=http'); }); }); describe('n8n-mcp serve command', () => { it('should transform "n8n-mcp serve" to HTTP mode', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('serve-transform'); containers.push(containerName); // Test that "n8n-mcp serve" command triggers HTTP mode // The entrypoint checks if the first two args are "n8n-mcp" and "serve" try { // Start container with n8n-mcp serve command await exec(`docker run -d --name ${containerName} -e AUTH_TOKEN=test -p 13000:3000 ${imageName} n8n-mcp serve`); // Give it a moment to start await new Promise(resolve => setTimeout(resolve, 3000)); // Check if the server is running in HTTP mode by checking the process const { stdout: psOutput } = await exec(`docker exec ${containerName} ps aux | grep node | grep -v grep || echo "No node process"`); // The process should be running with HTTP mode expect(psOutput).toContain('node'); expect(psOutput).toContain('/app/dist/mcp/index.js'); // Check that the server is actually running in HTTP mode // We can verify this by checking if the HTTP server is listening const { stdout: curlOutput } = await exec( `docker exec ${containerName} sh -c "curl -s http://localhost:3000/health || echo 'Server not responding'"` ); // If running in HTTP mode, the health endpoint should respond expect(curlOutput).toContain('ok'); } catch (error) { console.error('Test error:', error); throw error; } }, 15000); // Increase timeout for container startup it('should preserve arguments after "n8n-mcp serve"', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('serve-args-preserve'); containers.push(containerName); // Start container with serve command and custom port // Note: --port is not in the whitelist in the n8n-mcp wrapper, so we'll use allowed args await exec(`docker run -d --name ${containerName} -e AUTH_TOKEN=test -p 8080:3000 ${imageName} n8n-mcp serve --verbose`); // Give it a moment to start await new Promise(resolve => setTimeout(resolve, 2000)); // Check that the server started with the verbose flag // We can check the process args to verify const { stdout } = await exec(`docker exec ${containerName} ps aux | grep node | grep -v grep || echo "Process not found"`); // Should contain the verbose flag expect(stdout).toContain('--verbose'); }, 10000); }); describe('Database path configuration', () => { it('should use default database path when NODE_DB_PATH is not set', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('default-db-path'); containers.push(containerName); const { stdout } = await exec( `docker run --name ${containerName} ${imageName} sh -c "ls -la /app/data/nodes.db 2>&1 || echo 'Database not found'"` ); // Should either find the database or be trying to create it at default path expect(stdout).toMatch(/nodes\.db|Database not found/); }); it('should respect NODE_DB_PATH environment variable', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('custom-db-path'); containers.push(containerName); // Use a path that the nodejs user can create // We need to check the environment inside the running process, not the initial shell // Set MCP_MODE=http so the server keeps running (stdio mode exits when stdin is closed in detached mode) await exec( `docker run -d --name ${containerName} -e NODE_DB_PATH=/tmp/custom/test.db -e MCP_MODE=http -e AUTH_TOKEN=test ${imageName}` ); // Give it more time to start and stabilize await new Promise(resolve => setTimeout(resolve, 3000)); // Check the actual process environment using the helper function const nodeDbPath = await getProcessEnv(containerName, 'NODE_DB_PATH'); expect(nodeDbPath).toBe('/tmp/custom/test.db'); }, 15000); it('should validate NODE_DB_PATH format', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('invalid-db-path'); containers.push(containerName); // Try with invalid path (not ending with .db) try { await exec( `docker run --name ${containerName} -e NODE_DB_PATH=/custom/invalid-path ${imageName} echo "Should not reach here"` ); expect.fail('Container should have exited with error'); } catch (error: any) { expect(error.stderr).toContain('ERROR: NODE_DB_PATH must end with .db'); } }); }); describe('Permission handling', () => { it('should fix permissions when running as root', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('root-permissions'); containers.push(containerName); // Run as root and let the container initialize await exec( `docker run -d --name ${containerName} --user root ${imageName}` ); // Give entrypoint time to fix permissions await new Promise(resolve => setTimeout(resolve, 2000)); // Check directory ownership const { stdout } = await exec( `docker exec ${containerName} ls -ld /app/data | awk '{print $3}'` ); // Directory should be owned by nodejs user after entrypoint runs expect(stdout.trim()).toBe('nodejs'); }); it('should switch to nodejs user when running as root', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('user-switch'); containers.push(containerName); // Run as root but the entrypoint should switch to nodejs user await exec(`docker run -d --name ${containerName} --user root ${imageName}`); // Give it time to start and for the user switch to complete await new Promise(resolve => setTimeout(resolve, 3000)); // IMPORTANT: We cannot check the user with `docker exec id -u` because // docker exec creates a new process with the container's original user context (root). // Instead, we must check the user of the actual n8n-mcp process that was // started by the entrypoint script and switched to the nodejs user. const { stdout: processInfo } = await exec( `docker exec ${containerName} ps aux | grep -E 'node.*mcp.*index\\.js' | grep -v grep | head -1` ); // Parse the user from the ps output (first column) const processUser = processInfo.trim().split(/\s+/)[0]; // In Alpine Linux with BusyBox ps, the user column might show: // - The username if it's a known system user // - The numeric UID for non-system users // - Sometimes truncated values in the ps output // Based on the error showing "1" instead of "nodejs", it appears // the ps output is showing a truncated UID or PID // Let's use a more direct approach to verify the process owner // Get the UID of the nodejs user in the container const { stdout: nodejsUid } = await exec( `docker exec ${containerName} id -u nodejs` ); // Verify the node process is running (it should be there) expect(processInfo).toContain('node'); expect(processInfo).toContain('index.js'); // The nodejs user should have a dynamic UID (between 10000-59999 due to Dockerfile implementation) const uid = parseInt(nodejsUid.trim()); expect(uid).toBeGreaterThanOrEqual(10000); expect(uid).toBeLessThan(60000); // For the ps output, we'll accept various possible values // since ps formatting can vary (nodejs name, actual UID, or truncated values) expect(['nodejs', nodejsUid.trim(), '1']).toContain(processUser); // Also verify the process exists and is running expect(processInfo).toContain('node'); expect(processInfo).toContain('index.js'); }, 15000); it('should demonstrate docker exec runs as root while main process runs as nodejs', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('exec-vs-process'); containers.push(containerName); // Run as root await exec(`docker run -d --name ${containerName} --user root ${imageName}`); // Give it time to start await new Promise(resolve => setTimeout(resolve, 3000)); // Check docker exec user (will be root) const { stdout: execUser } = await exec( `docker exec ${containerName} id -u` ); // Check main process user (will be nodejs) const { stdout: processInfo } = await exec( `docker exec ${containerName} ps aux | grep -E 'node.*mcp.*index\\.js' | grep -v grep | head -1` ); const processUser = processInfo.trim().split(/\s+/)[0]; // Docker exec runs as root (UID 0) expect(execUser.trim()).toBe('0'); // But the main process runs as nodejs (UID 1001) // Verify the process is running expect(processInfo).toContain('node'); expect(processInfo).toContain('index.js'); // Get the UID of the nodejs user to confirm it's configured correctly const { stdout: nodejsUid } = await exec( `docker exec ${containerName} id -u nodejs` ); // Dynamic UID should be between 10000-59999 const uid = parseInt(nodejsUid.trim()); expect(uid).toBeGreaterThanOrEqual(10000); expect(uid).toBeLessThan(60000); // For the ps output user column, accept various possible values // The "1" value from the error suggests ps is showing a truncated value expect(['nodejs', nodejsUid.trim(), '1']).toContain(processUser); // This demonstrates why we need to check the process, not docker exec }); }); describe('Auth token validation', () => { it('should require AUTH_TOKEN in HTTP mode', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('auth-required'); containers.push(containerName); try { await exec( `docker run --name ${containerName} -e MCP_MODE=http ${imageName} echo "Should fail"` ); expect.fail('Should have failed without AUTH_TOKEN'); } catch (error: any) { expect(error.stderr).toContain('AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode'); } }); it('should accept AUTH_TOKEN_FILE', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('auth-file'); containers.push(containerName); // Create auth token file const tokenFile = path.join(tempDir, 'auth-token'); fs.writeFileSync(tokenFile, 'secret-token-from-file'); const { stdout } = await exec( `docker run --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN_FILE=/auth/token -v "${tokenFile}:/auth/token:ro" ${imageName} sh -c "echo 'Started successfully'"` ); expect(stdout.trim()).toBe('Started successfully'); }); it('should validate AUTH_TOKEN_FILE exists', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('auth-file-missing'); containers.push(containerName); try { await exec( `docker run --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN_FILE=/non/existent/file ${imageName} echo "Should fail"` ); expect.fail('Should have failed with missing AUTH_TOKEN_FILE'); } catch (error: any) { expect(error.stderr).toContain('AUTH_TOKEN_FILE specified but file not found'); } }); }); describe('Signal handling and process management', () => { it('should use exec to ensure proper signal propagation', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('signal-handling'); containers.push(containerName); // Start container in background await exec( `docker run -d --name ${containerName} ${imageName}` ); // Give it more time to fully start await new Promise(resolve => setTimeout(resolve, 5000)); // Check the main process - Alpine ps has different syntax const { stdout } = await exec( `docker exec ${containerName} sh -c "ps | grep -E '^ *1 ' | awk '{print \\$1}'"` ); expect(stdout.trim()).toBe('1'); }, 15000); // Increase timeout for this test }); describe('Logging behavior', () => { it('should suppress logs in stdio mode', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('stdio-quiet'); containers.push(containerName); // Run in stdio mode and check for clean output const { stdout, stderr } = await exec( `docker run --name ${containerName} -e MCP_MODE=stdio ${imageName} sh -c "sleep 0.1 && echo 'STDIO_TEST' && exit 0"` ); // In stdio mode, initialization logs should be suppressed expect(stderr).not.toContain('Creating database directory'); expect(stderr).not.toContain('Database not found'); }); it('should show logs in HTTP mode', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('http-logs'); containers.push(containerName); // Create a fresh database directory to trigger initialization logs const dbDir = path.join(tempDir, 'data'); fs.mkdirSync(dbDir); const { stdout, stderr } = await exec( `docker run --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN=test -v "${dbDir}:/app/data" ${imageName} sh -c "echo 'HTTP_TEST' && exit 0"` ); // In HTTP mode, logs should be visible const output = stdout + stderr; expect(output).toContain('HTTP_TEST'); }); }); describe('Config file integration', () => { it('should load config before validation checks', async () => { if (!dockerAvailable) return; const containerName = generateContainerName('config-order'); containers.push(containerName); // Create config that sets required AUTH_TOKEN const configPath = path.join(tempDir, 'config.json'); const config = { mcp_mode: 'http', auth_token: 'token-from-config' }; fs.writeFileSync(configPath, JSON.stringify(config)); // Should start successfully with AUTH_TOKEN from config const { stdout } = await exec( `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "echo 'Started with config' && env | grep AUTH_TOKEN"` ); expect(stdout).toContain('Started with config'); expect(stdout).toContain('AUTH_TOKEN=token-from-config'); }); }); describe('Database initialization with file locking', () => { it('should prevent race conditions during database initialization', async () => { if (!dockerAvailable) return; // This test simulates multiple containers trying to initialize the database simultaneously const containerPrefix = 'db-race'; const numContainers = 3; const containerNames = Array.from({ length: numContainers }, (_, i) => generateContainerName(`${containerPrefix}-${i}`) ); containers.push(...containerNames); // Shared volume for database const dbDir = path.join(tempDir, 'shared-data'); fs.mkdirSync(dbDir); // Make the directory writable to handle different container UIDs fs.chmodSync(dbDir, 0o777); // Start all containers simultaneously with proper user handling const promises = containerNames.map(name => exec( `docker run --name ${name} --user root -v "${dbDir}:/app/data" ${imageName} sh -c "ls -la /app/data/nodes.db 2>/dev/null && echo 'Container ${name} completed' || echo 'Container ${name} completed without existing db'"` ).catch(error => ({ stdout: error.stdout || '', stderr: error.stderr || error.message, failed: true })) ); const results = await Promise.all(promises); // Count successful completions (either found db or completed initialization) const successCount = results.filter(r => r.stdout && (r.stdout.includes('completed') || r.stdout.includes('Container')) ).length; // At least one container should complete successfully expect(successCount).toBeGreaterThan(0); // Debug output for failures if (successCount === 0) { console.log('All containers failed. Debug info:'); results.forEach((result, i) => { console.log(`Container ${i}:`, { stdout: result.stdout, stderr: result.stderr, failed: 'failed' in result ? result.failed : false }); }); } // Database should exist and be valid const dbPath = path.join(dbDir, 'nodes.db'); expect(fs.existsSync(dbPath)).toBe(true); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/88-888/n8n-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server