Root cause
Authentication & access control introduced
feat: add n8n integration with MCP Client Tool support
How AI contributed: Direct introductionPublished Apr 10, 2026 in
czlonkowski/n8n-mcpAll 11 findings in this repository
Summary Several HTTP transport endpoints in n8n-mcp lacked proper authentication, and the health check endpoint exposed sensitive operational metadata without credentials. Impact An unauthenticated attacker with network access to the n8n-mcp HTTP server could disrupt active MCP sessions and gather information useful for further attacks. Patches Fixed in v2.47.6. All MCP session endpoints now require Bearer authentication. The health check endpoint has been reduced to a minimal liveness response. Workarounds If you cannot upgrade immediately: · Restrict network access to the HTTP server using firewall rules, reverse proxy IP allowlists, or a VPN so that only trusted clients can reach it. · Use stdio mode (MCP_MODE=stdio) instead of HTTP mode. The stdio transport does not expose any HTTP endpoints and is unaffected by this vulnerability. Upgrading to v2.47.6 is still strongly recommended. Credit Reported by @yotampe-pluto.
Claude Code added GET /mcp and DELETE /mcp handlers in src/http-server-single-session.ts that only checked for the mcp-session-id header, so an unauthenticated caller could terminate active sessions. The same change exposed session ids, session counts and token metadata through /health and added an unauthenticated test route, and the fix requires rate-limited bearer authentication, drops the test route and reduces /health to liveness.
Root cause
feat: add n8n integration with MCP Client Tool support
Fix
The fix requires rate-limited bearer authentication on GET and DELETE /mcp, removes the unauthenticated test route, and reduces /health to a minimal liveness response.
Case facts
@@ -6,25 +6,36 @@ */ import express from 'express'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';+import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { N8NDocumentationMCPServer } from './mcp/server'; 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';+import { v4 as uuidv4 } from 'uuid';+import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; dotenv.config(); +// Protocol version constant+const PROTOCOL_VERSION = '2024-11-05';+ interface Session { server: N8NDocumentationMCPServer;- transport: StreamableHTTPServerTransport;+ transport: StreamableHTTPServerTransport | SSEServerTransport; lastAccess: Date; sessionId: string;+ initialized: boolean;+ isSSE: boolean; } export class SingleSessionHTTPServer {- private session: Session | null = null;+ // Map to store transports by session ID (following SDK pattern)+ private transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};+ private servers: { [sessionId: string]: N8NDocumentationMCPServer } = {};+ private session: Session | null = null; // Keep for SSE compatibility private consoleManager = new ConsoleManager(); private expressServer: any; private sessionTimeout = 30 * 60 * 1000; // 30 minutes@@ -33,8 +44,10 @@ export class SingleSessionHTTPServer { constructor() { // Validate environment on construction this.validateEnvironment();+ // No longer pre-create session - will be created per initialize request following SDK pattern } + /** * Load auth token from environment variable or file */@@ -97,93 +110,171 @@ export class SingleSessionHTTPServer { } } + /**- * Handle incoming MCP request+ * Handle incoming MCP request using proper SDK pattern */ async handleRequest(req: express.Request, res: express.Response): Promise<void> { const startTime = Date.now(); // Wrap all operations to prevent console interference return this.consoleManager.wrapOperation(async () => { try {- // Ensure we have a valid session- if (!this.session || this.isExpired()) {- await this.resetSession();- }+ const sessionId = req.headers['mcp-session-id'] as string | undefined;+ const isInitialize = req.body ? isInitializeRequest(req.body) : false; - // Update last access time- this.session!.lastAccess = new Date();+ // Log comprehensive incoming request details for debugging+ logger.info('handleRequest: Processing MCP request - SDK PATTERN', {+ requestId: req.get('x-request-id') || 'unknown',+ sessionId: sessionId,+ method: req.method,+ url: req.url,+ bodyType: typeof req.body,+ bodyContent: req.body ? JSON.stringify(req.body, null, 2) : 'undefined',+ existingTransports: Object.keys(this.transports),+ isInitializeRequest: isInitialize+ }); - // Handle request with existing transport- logger.debug('Calling transport.handleRequest...');- await this.session!.transport.handleRequest(req, res);- logger.debug('transport.handleRequest completed');@@ -0,0 +1,540 @@+import { describe, it, expect, beforeEach, afterEach, vi, MockedFunction } from 'vitest';+import type { Request, Response, NextFunction } from 'express';+import { SingleSessionHTTPServer } from '../../src/http-server-single-session';++// Mock dependencies+vi.mock('../../src/utils/logger', () => ({+ logger: {+ info: vi.fn(),+ error: vi.fn(),+ warn: vi.fn(),+ debug: vi.fn()+ }+}));++vi.mock('dotenv');++vi.mock('../../src/mcp/server', () => ({+ N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({+ connect: vi.fn().mockResolvedValue(undefined)+ }))+}));++vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({+ StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({+ handleRequest: vi.fn().mockImplementation(async (req: any, res: any) => {+ // Simulate successful MCP response+ if (process.env.N8N_MODE === 'true') {+ res.setHeader('Mcp-Session-Id', 'single-session');+ }+ res.status(200).json({+ jsonrpc: '2.0',+ result: { success: true },+ id: 1+ });+ }),+ close: vi.fn().mockResolvedValue(undefined)+ }))+}));++// Create a mock console manager instance+const mockConsoleManager = {+ wrapOperation: vi.fn().mockImplementation(async (fn: () => Promise<any>) => {+ return await fn();+ })+};++vi.mock('../../src/utils/console-manager', () => ({+ ConsoleManager: vi.fn(() => mockConsoleManager)+}));++vi.mock('../../src/utils/url-detector', () => ({+ getStartupBaseUrl: vi.fn((host: string, port: number) => `http://localhost:${port || 3000}`),+ formatEndpointUrls: vi.fn((baseUrl: string) => ({+ health: `${baseUrl}/health`,+ mcp: `${baseUrl}/mcp`+ })),+ detectBaseUrl: vi.fn((req: any, host: string, port: number) => `http://localhost:${port || 3000}`)+}));++vi.mock('../../src/utils/version', () => ({+ PROJECT_VERSION: '2.8.1'+}));++// Create Express app mock+const mockHandlers: { [key: string]: any[] } = {+ get: [],+ post: [],+ use: []+};++const mockExpressApp = {+ get: vi.fn((path: string, ...handlers: any[]) => {+ mockHandlers.get.push({ path, handlers });+ return mockExpressApp;+ }),+ post: vi.fn((path: string, ...handlers: any[]) => {+ mockHandlers.post.push({ path, handlers });+ return mockExpressApp;+ }),+ use: vi.fn((handler: any) => {+ mockHandlers.use.push(handler);+ return mockExpressApp;+ }),+ set: vi.fn(),+ listen: vi.fn((port: number, host: string, callback?: () => void) => {+ if (callback) callback();+ return {+ on: vi.fn(),+ close: vi.fn((cb: () => void) => cb()),@@ -0,0 +1,514 @@+# n8n MCP Client Tool Integration - Implementation Plan (Simplified)++## Overview++This document provides a **simplified** implementation plan for making n8n-mcp compatible with n8n's MCP Client Tool (v1.1). Based on expert review, we're taking a minimal approach that extends the existing single-session server rather than creating new architecture.++## Key Design Principles++1. **Minimal Changes**: Extend existing single-session server with n8n compatibility mode+2. **No Overengineering**: No complex session management or multi-session architecture+3. **Docker-Native**: Separate Docker image for n8n deployment+4. **Remote Deployment**: Designed to run alongside n8n in production+5. **Backward Compatible**: Existing functionality remains unchanged++## Prerequisites++- Docker and Docker Compose+- n8n version 1.104.2 or higher (with MCP Client Tool v1.1)+- Basic understanding of Docker networking++## Implementation Approach++Instead of creating new multi-session architecture, we'll extend the existing single-session server with an n8n compatibility mode. This approach was recommended by all three expert reviewers as simpler and more maintainable.++## Architecture Changes++```+src/+├── http-server-single-session.ts # MODIFY: Add n8n mode flag+└── mcp/+ └── server.ts # NO CHANGES NEEDED++Docker/+├── Dockerfile.n8n # NEW: n8n-specific image+├── docker-compose.n8n.yml # NEW: Simplified stack+└── .github/workflows/+ └── docker-build-n8n.yml # NEW: Build workflow+```++## Implementation Steps++### Step 1: Modify Existing Single-Session Server++#### 1.1 Update `src/http-server-single-session.ts`++Add n8n compatibility mode to the existing server with minimal changes:++```typescript+// Add these constants at the top (after imports)+const PROTOCOL_VERSION = "2024-11-05";+const N8N_MODE = process.env.N8N_MODE === 'true';++// In the constructor or start method, add logging+if (N8N_MODE) {+ logger.info('Running in n8n compatibility mode');+}++// In setupRoutes method, add the protocol version endpoint+if (N8N_MODE) {+ app.get('/mcp', (req, res) => {+ res.json({+ protocolVersion: PROTOCOL_VERSION,+ serverInfo: {+ name: "n8n-mcp",+ version: PROJECT_VERSION,+ capabilities: {+ tools: true,+ resources: false,+ prompts: false,+ },+ },+ });+ });+}++// In handleMCPRequest method, add session header+if (N8N_MODE && this.session) {+ res.setHeader('Mcp-Session-Id', this.session.sessionId);+}++// Update error handling to use JSON-RPC format+catch (error) {+ logger.error('MCP request error:', error);+ + if (N8N_MODE) {+ res.status(500).json({+ jsonrpc: '2.0',+ error: {+ code: -32603,@@ -948,7 +948,43 @@ export class SingleSessionHTTPServer { }); next(); });- ++ // SECURITY: Rate limiting for authentication endpoints+ // Prevents brute force attacks and DoS+ // See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02)+ // Declared before route registrations so all authenticated endpoints+ // (including GET /mcp and DELETE /mcp) can reference it.+ const authLimiter = rateLimit({+ windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW || '900000'), // 15 minutes+ max: parseInt(process.env.AUTH_RATE_LIMIT_MAX || '20'), // 20 authentication attempts per IP+ message: {+ jsonrpc: '2.0',+ error: {+ code: -32000,+ message: 'Too many authentication attempts. Please try again later.'+ },+ id: null+ },+ standardHeaders: true, // Return rate limit info in `RateLimit-*` headers+ legacyHeaders: false, // Disable `X-RateLimit-*` headers+ skipSuccessfulRequests: true, // Only count failed auth attempts (#617)+ handler: (req, res) => {+ logger.warn('Rate limit exceeded', {+ ip: req.ip,+ userAgent: req.get('user-agent'),+ event: 'rate_limit'+ });+ res.status(429).json({+ jsonrpc: '2.0',+ error: {+ code: -32000,+ message: 'Too many authentication attempts'+ },+ id: null+ });+ }+ });+ // Root endpoint with API information app.get('/', (req, res) => { const port = parseInt(process.env.PORT || '3000');@@ -975,96 +1011,33 @@ export class SingleSessionHTTPServer { authentication: { type: 'Bearer Token', header: 'Authorization: Bearer <token>',- required_for: ['POST /mcp', 'GET /sse', 'POST /messages']+ required_for: ['POST /mcp', 'GET /mcp', 'DELETE /mcp', 'GET /sse', 'POST /messages'] }, documentation: 'https://github.com/czlonkowski/n8n-mcp' }); }); // Health check endpoint (no body parsing needed for GET)+ // Intentionally minimal: used by Docker HEALTHCHECK and CI without credentials.+ // Must not disclose session IDs, token metadata, memory stats, environment+ // flags, or any other operationally sensitive detail — those belong behind+ // auth. status/version/uptime/timestamp is the standard liveness envelope. app.get('/health', (req, res) => {- const activeTransports = Object.keys(this.transports);- const activeServers = Object.keys(this.servers);- const sessionMetrics = this.getSessionMetrics();- const isProduction = process.env.NODE_ENV === 'production';- const isDefaultToken = this.authToken === 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh';- - res.json({ - status: 'ok', - mode: 'sdk-pattern-transports',+ res.json({+ status: 'ok', version: PROJECT_VERSION,- environment: process.env.NODE_ENV || 'development', uptime: Math.floor(process.uptime()),- sessions: {- active: sessionMetrics.activeSessions,- total: sessionMetrics.totalSessions,- expired: sessionMetrics.expiredSessions,- max: MAX_SESSIONS,- usage: `${sessionMetrics.activeSessions}/${MAX_SESSIONS}`,- sessionIds: activeTransports- },- security: {- production: isProduction,- defaultToken: isDefaultToken,- tokenLength: this.authToken?.length || 0- },- activeTransports: activeTransports.length, // Legacy fieldAI-assisted change 4a46babdf6055f91120f6015dc2464df83c1f4d60bd6865458f08117e8cceb80 · Fix e0eb5489703c35b59b93b471d3bdda72061f7bc2735627da359c02f1fa36403d