Root cause
Authentication & access control introduced
feat: add n8n integration with MCP Client Tool support
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Apr 23, 2026 in
czlonkowski/n8n-mcpAll 11 findings in this repository
The AI-written POST /mcp handler recorded a DETAILED DEBUG log line for every request that included req.headers and the whole JSON-RPC body through JSON.stringify(req.body, null, 2). Because the record was written before the caller was authenticated, bearer tokens, x-n8n-key API keys, and request parameters were persisted even for requests that came back with an authorization error, exposing them to anyone with log or monitoring access. The fix removes pre-authentication request logging, replaces the body dump with a metadata summary, and redacts sensitive headers.
The handler in src/http-server-single-session.ts logged request headers and the stringified body at info level before verifying the caller, so a request rejected with HTTP 401 still wrote its Authorization bearer token and x-n8n-key values into the application log.
Root cause
feat: add n8n integration with MCP Client Tool support
AI-assisted change: Claude
Fix
Removes pre-authentication request-detail logging, replaces raw JSON-RPC payloads with metadata-only summaries, and redacts sensitive headers in post-authentication logs.
Fix by Romuald Członkowski · no AI marker found
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');@@ -56,21 +56,26 @@ export class Logger { } private log(level: LogLevel, levelName: string, message: string, ...args: any[]): void {+ // Allow ERROR level logs through in more cases for debugging+ const allowErrorLogs = level === LogLevel.ERROR && (this.isHttp || process.env.DEBUG === 'true');+ // Check environment variables FIRST, before level check- // In stdio mode, suppress ALL console output to avoid corrupting JSON-RPC+ // In stdio mode, suppress ALL console output to avoid corrupting JSON-RPC (except errors when debugging) // Also suppress in test mode unless debug is explicitly enabled if (this.isStdio || this.isDisabled || (this.isTest && process.env.DEBUG !== 'true')) {- // Silently drop all logs in stdio/test mode- return;+ // Allow error logs through if debugging is enabled+ if (!allowErrorLogs) {+ return;+ } } - if (level <= this.config.level) {+ if (level <= this.config.level || allowErrorLogs) { const formattedMessage = this.formatMessage(levelName, message); - // In HTTP mode during request handling, suppress console output+ // In HTTP mode during request handling, suppress console output (except errors) // The ConsoleManager will handle this, but we add a safety check- if (this.isHttp && process.env.MCP_REQUEST_ACTIVE === 'true') {- // Silently drop the log during active MCP requests+ if (this.isHttp && process.env.MCP_REQUEST_ACTIVE === 'true' && !allowErrorLogs) {+ // Silently drop the log during active MCP requests (except errors) return; } @@ -288,7 +288,7 @@ export async function startFixedHTTPServer() { ip: req.ip, userAgent: req.get('user-agent'), reason: 'invalid_auth_format',- headerPrefix: authHeader.substring(0, 10) + '...' // Log first 10 chars for debugging+ headerPrefix: authHeader.substring(0, Math.min(authHeader.length, 10)) + '...' // Log first 10 chars for debugging }); res.status(401).json({ jsonrpc: '2.0',diff --git a/src/http-server-single-session.ts b/src/http-server-single-session.tsindex 32903a2..1015e17 100644--- a/src/http-server-single-session.ts+++ b/src/http-server-single-session.ts@@ -11,6 +11,7 @@ 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 { redactHeaders, summarizeMcpBody } from './utils/redaction'; import { AuthManager } from './utils/auth'; import { readFileSync } from 'fs'; import dotenv from 'dotenv';@@ -557,14 +558,13 @@ export class SingleSessionHTTPServer { const sessionId = req.headers['mcp-session-id'] as string | undefined; const isInitialize = req.body ? isInitializeRequest(req.body) : false; - // Log comprehensive incoming request details for debugging+ // SECURITY (GHSA-pfm2-2mhg-8wpx): log body summary only, not payload. 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',+ body: summarizeMcpBody(req.body), existingTransports: Object.keys(this.transports), isInitializeRequest: isInitialize });@@ -1270,23 +1270,6 @@ export class SingleSessionHTTPServer { // Main MCP endpoint with authentication and rate limiting app.post('/mcp', authLimiter, jsonParser, async (req: express.Request, res: express.Response): Promise<void> => {- // Log comprehensive debug info about the request- logger.info('POST /mcp request received - DETAILED DEBUG', {- headers: req.headers,- readable: req.readable,- readableEnded: req.readableEnded,- complete: req.complete,- bodyType: typeof req.body,- bodyContent: req.body ? JSON.stringify(req.body, null, 2) : 'undefined',- contentLength: req.get('content-length'),- contentType: req.get('content-type'),- userAgent: req.get('user-agent'),- ip: req.ip,- method: req.method,- url: req.url,- originalUrl: req.originalUrl- });- // Handle connection close to immediately clean up sessions const sessionId = req.headers['mcp-session-id'] as string | undefined; // Only add event listener if the request object supports it (not in test mocks)@@ -1320,7 +1303,14 @@ export class SingleSessionHTTPServer { if (!this.authenticateRequest(req, res)) return; - logger.info('Authentication successful - proceeding to handleRequest', {+ // SECURITY (GHSA-pfm2-2mhg-8wpx): redacted summary only, post-auth.+ logger.debug('POST /mcp authenticated', {+ ip: req.ip,+ userAgent: req.get('user-agent'),+ contentType: req.get('content-type'),+ contentLength: req.get('content-length'),+ headers: redactHeaders(req.headers),+ body: summarizeMcpBody(req.body), activeSessions: this.getActiveSessionCount() }); diff --git a/src/utils/redaction.ts b/src/utils/redaction.tsnew file mode 100644index 0000000..442f296--- /dev/null+++ b/src/utils/redaction.ts@@ -0,0 +1,52 @@+/**+ * Redaction helpers for log output. The project logger has no built-in+ * redaction, so callers must scrub sensitive values before passing them in.+ */++const SENSITIVE_HEADER_NAMES = new Set([+ 'authorization',+ 'proxy-authorization',+ 'cookie',+ 'set-cookie',+ 'x-n8n-key',+ 'x-n8n-url',+]);++export const REDACTED = '[REDACTED]';++/**+ * Return a shallow copy of the headers object with sensitive values replaced+ * by a fixed placeholder. Header names are matched case-insensitively.+ */+export function redactHeaders(+ headers: Record<string, unknown> | undefined | null,+): Record<string, unknown> {+ if (!headers || typeof headers !== 'object') {+ return {};+ }+ const out: Record<string, unknown> = {};+ for (const [key, value] of Object.entries(headers)) {+ out[key] = SENSITIVE_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value;+ }+ return out;+}++/**+ * Reduce a JSON-RPC request body to a safe metadata summary that is suitable+ * for logging. Intentionally drops params/payload content.+ */+export function summarizeMcpBody(body: unknown): Record<string, unknown> {+ if (body === undefined || body === null) {+ return { bodyType: body === null ? 'null' : 'undefined' };+ }+ if (typeof body !== 'object' || Array.isArray(body)) {+ return { bodyType: Array.isArray(body) ? 'array' : typeof body };+ }+ const b = body as Record<string, unknown>;+ return {+ jsonrpc: typeof b.jsonrpc === 'string' ? b.jsonrpc : undefined,+ method: typeof b.method === 'string' ? b.method : undefined,+ id: typeof b.id === 'string' || typeof b.id === 'number' ? b.id : undefined,+ hasParams: b.params !== undefined && b.params !== null,+ };+}AI-assisted change de2f2fc5a9044cf85b58d355a6d1ac14f38d7d0e5f905a1c65f1bd514d720b9a · Fix 7c9522c00d0e66898261e1706f74ab28e9c75a577b8145cbf900e59ad718349f