How Session Persistence Works in HTTP Mode for n8n-MCP Multi-Tenant Deployments

Session persistence in n8n-MCP's HTTP mode captures tenant-specific session metadata—including API endpoints and keys—into serializable state objects via exportSessionState(), allowing containers to restore multi-tenant contexts after restarts without losing active n8n instance connections.

In multi-tenant deployments where ENABLE_MULTI_TENANT=true, the n8n-MCP HTTP server creates isolated transport and MCP server pairs for each client session identified by headers like x-n8n-url, x-n8n-key, and x-instance-id. Because cloud-native containers restart frequently, the system implements a session-persistence API in src/http-server-single-session.ts that serializes only safe-to-export context data, while requiring operators to encrypt sensitive fields before disk storage.

What Gets Persisted in Session State?

The persistence layer stores minimal, serializable data defined in src/types/session-state.ts. Only metadata and context required to reconstruct a tenant connection survive process restarts; transient transport objects are recreated lazily upon first request.

Core Session Fields

Each exported session contains the following fields:

Field Purpose
sessionId Unique identifier matching the transport/SDK header value
metadata.createdAt / metadata.lastAccess ISO-8601 timestamps for expiration checks during restore
context.n8nApiUrl Tenant's n8n instance endpoint from the x-n8n-url header
context.n8nApiKey Plain-text API key (requires external encryption before persistence)
context.instanceId Optional tenant-provided identifier; defaults to sessionId if omitted
context.sessionId Optional override when a proxy supplies its own value
context.metadata Arbitrary client data such as user-agent or IP address

Security Warning: The exported payload contains plaintext n8n API keys. The caller must encrypt this data before writing to disk or remote storage.

Exporting Sessions Before Shutdown

When a container receives a shutdown signal (SIGTERM) or when the host requests a snapshot, the server invokes exportSessionState() in src/http-server-single-session.ts (lines 1574–1580). This method iterates over this.sessionMetadata, skipping duplicates, expired entries, and sessions lacking valid context, then returns an array of SessionState objects.

// src/http-server-single-session.ts – export implementation (simplified)
public exportSessionState(): SessionState[] {
  const sessions: SessionState[] = [];
  const seenSessionIds = new Set<string>();

  for (const sessionId of Object.keys(this.sessionMetadata)) {
    if (seenSessionIds.has(sessionId)) continue;
    if (this.isSessionExpired(sessionId)) continue;

    const metadata = this.sessionMetadata[sessionId];
    const context = this.sessionContexts[sessionId];
    if (!context?.n8nApiUrl || !context?.n8nApiKey) continue;

    seenSessionIds.add(sessionId);
    sessions.push({
      sessionId,
      metadata: {
        createdAt: metadata.createdAt.toISOString(),
        lastAccess: metadata.lastAccess.toISOString(),
      },
      context: {
        n8nApiUrl: context.n8nApiUrl,
        n8nApiKey: context.n8nApiKey,
        instanceId: context.instanceId || sessionId,
        sessionId: context.sessionId,
        metadata: context.metadata,
      },
    });
  }

  logger.info(`Exported ${sessions.length} session(s) for persistence`);
  logSecurityEvent('session_export', { count: sessions.length });
  return sessions;
}

The implementation logs a session_export security event and emits a clear warning: exported data contains plaintext API keys that must be encrypted by the downstream persistence layer.

Practical Export with Encryption

import { SingleSessionHTTPServer } from './http-server-single-session';
import { writeFile } from 'fs/promises';
import { createCipheriv, randomBytes } from 'crypto';

const server = new SingleSessionHTTPServer();

async function persistSessions() {
  const sessions = server.exportSessionState();
  
  // Encrypt before writing (example using AES-256-GCM)
  const key = Buffer.from(process.env.SESSION_ENCRYPTION_KEY!, 'hex');
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const encrypted = Buffer.concat([
    cipher.update(JSON.stringify(sessions), 'utf8'),
    cipher.final(),
  ]);
  const tag = cipher.getAuthTag();
  
  const payload = Buffer.concat([iv, tag, encrypted]);
  await writeFile('/data/sessions.bin', payload);
}

process.on('SIGTERM', async () => {
  await persistSessions();
  process.exit(0);
});

Restoring Sessions on Startup

On startup, the host feeds previously saved state into restoreSessionState() (lines 1622–1648 in src/http-server-single-session.ts). This method validates each entry, enforces the MAX_SESSIONS limit, and repopulates internal maps while leaving transport objects empty—these are instantiated lazily when the first HTTP request arrives for that session.

// src/http-server-single-session.ts – restore implementation (simplified)
public restoreSessionState(sessions: SessionState[]): number {
  let restoredCount = 0;

  for (const sessionState of sessions) {
    if (!sessionState?.sessionId) continue;
    if (Object.keys(this.sessionMetadata).length >= MAX_SESSIONS) break;
    if (this.sessionMetadata[sessionState.sessionId]) continue;

    const createdAt = new Date(sessionState.metadata.createdAt);
    const lastAccess = new Date(sessionState.metadata.lastAccess);
    if (isNaN(createdAt.getTime()) || isNaN(lastAccess.getTime())) continue;
    if (Date.now() - lastAccess.getTime() > this.sessionTimeout) continue;

    const validation = validateInstanceContext(sessionState.context);
    if (!validation.valid) {
      logSecurityEvent('session_restore_failed', { 
        sessionId: sessionState.sessionId, 
        reason: validation.errors?.join(', ') 
      });
      continue;
    }

    this.sessionMetadata[sessionState.sessionId] = { createdAt, lastAccess };
    this.sessionContexts[sessionState.sessionId] = {
      n8nApiUrl: sessionState.context.n8nApiUrl,
      n8nApiKey: sessionState.context.n8nApiKey,
      instanceId: sessionState.context.instanceId,
      sessionId: sessionState.context.sessionId,
      metadata: sessionState.context.metadata,
    };

    logSecurityEvent('session_restore', { 
      sessionId: sessionState.sessionId, 
      instanceId: sessionState.context.instanceId 
    });
    restoredCount++;
  }

  logger.info(`Restored ${restoredCount}/${sessions.length} session(s) from persistence`);
  return restoredCount;
}

Sessions that pass validation enter a dormant state—metadata and context exist, but no StreamableHTTPServerTransport or MCP server instance runs until a client sends a request with the matching mcp-session-id header.

Practical Restore with Decryption

import { SingleSessionHTTPServer } from './http-server-single-session';
import { readFile } from 'fs/promises';
import { createDecipheriv } from 'crypto';

async function loadSessions(): Promise<any[]> {
  const data = await readFile('/data/sessions.bin');
  const iv = data.slice(0, 12);
  const tag = data.slice(12, 28);
  const ciphertext = data.slice(28);
  
  const key = Buffer.from(process.env.SESSION_ENCRYPTION_KEY!, 'hex');
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(tag);
  
  const decrypted = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final()
  ]);
  return JSON.parse(decrypted.toString('utf8'));
}

(async () => {
  const server = new SingleSessionHTTPServer();
  const saved = await loadSessions();
  const restored = server.restoreSessionState(saved);
  console.log(`Restored ${restored} sessions`);
})();

Session Lifecycle and HTTP Request Flow

Understanding the interaction between HTTP requests and persisted sessions clarifies how n8n-MCP maintains continuity across container restarts.

Session Initialization

When a client sends an Initialize request (isInitializeRequest), the server creates a new transport and MCP server pair, stores them in this.transports and this.servers, and records the tenant context extracted from x-n8n-url, x-n8n-key, and optional x-instance-id headers.

Dormant Sessions and Lazy Transport Creation

If restoreSessionState() repopulated a session from disk but no client has connected yet, the session remains dormant—it has metadata and context but no active transport. The first subsequent HTTP request bearing the correct mcp-session-id header triggers instantiation of a fresh StreamableHTTPServerTransport exactly as during normal initialization, binding the new transport to the restored tenant context.

Automatic Cleanup

A background task runs every 5 minutes (SESSION_CLEANUP_INTERVAL) to remove entries whose lastAccess exceeds sessionTimeout (default 5 minutes, configurable via SESSION_TIMEOUT_MINUTES). This prevents restored sessions that are never reused from accumulating memory indefinitely.

Security Considerations for Multi-Tenant Data

The session persistence mechanism delegates encryption responsibility to the operator. The raw export contains sensitive tenant credentials in plaintext, making external encryption mandatory before network or disk storage. All restoration failures, exports, and max-session-limit breaches generate security audit logs via logSecurityEvent() with event types including session_export, session_restore, session_restore_failed, and max_sessions_reached.

Programmatic Access via N8NMCPEngine

For higher-level integration, the N8NMCPEngine wrapper in src/mcp-engine.ts exposes identical persistence methods that delegate to the underlying HTTP server.

import { N8NMCPEngine } from './mcp-engine';
import { readFile, writeFile } from 'fs/promises';

const engine = new N8NMCPEngine();

// Export current sessions
const sessions = engine.exportSessionState();
await writeFile('sessions.json', JSON.stringify(sessions, null, 2));

// Restore on next startup
const loaded = JSON.parse(await readFile('sessions.json', 'utf8'));
engine.restoreSessionState(loaded);

Key reference: N8NMCPEngine.exportSessionState() and restoreSessionState() in src/mcp-engine.ts (line 139).

Summary

  • Session state in n8n-MCP HTTP mode stores only serializable metadata and tenant context in src/types/session-state.ts, excluding active transport objects.
  • exportSessionState() (lines 1574–1580 in src/http-server-single-session.ts) serializes valid, non-expired sessions while logging security events; operators must encrypt the resulting payload externally.
  • restoreSessionState() (lines 1622–1648) validates timestamp formats, enforces MAX_SESSIONS limits, and leaves sessions dormant until incoming HTTP requests trigger lazy transport initialization.
  • Dormant sessions retain tenant context (n8nApiUrl, n8nApiKey, instanceId) across container restarts without maintaining active server objects, enabling seamless multi-tenant continuity in ephemeral environments.
  • Security audit logging tracks all persistence operations, while automatic cleanup every 5 minutes prevents expired sessions from consuming resources.

Frequently Asked Questions

What headers identify a tenant session in n8n-MCP?

In HTTP mode, the server identifies tenants through the x-n8n-url, x-n8n-key, and optional x-instance-id headers. Subsequent requests within the same session must include the mcp-session-id header to match against stored sessionMetadata and sessionContexts maps.

How does n8n-MCP handle sessions when a container restarts?

When ENABLE_MULTI_TENANT=true, the host calls exportSessionState() before shutdown to capture tenant contexts, then encrypts and stores the payload externally. On startup, restoreSessionState() repopulates internal maps without creating transport objects; these are instantiated lazily when clients reconnect, allowing seamless continuity of n8n instance connections across container lifecycles.

Why are API keys stored in plaintext during session export?

The exportSessionState() method in src/http-server-single-session.ts intentionally exports n8nApiKey as plain text to keep the core library agnostic of specific encryption implementations. The code emits an explicit security warning logging event, requiring the deployment operator to encrypt the payload (for example, using AES-256-GCM or a KMS) before persisting to disk, S3, or etcd.

What limits the number of restored sessions?

The restoreSessionState() method checks Object.keys(this.sessionMetadata).length >= MAX_SESSIONS before processing each entry, silently dropping excess records to prevent memory exhaustion. This guard ensures that even if a persistence backend contains thousands of stale sessions, the active process only loads a configurable maximum into resident memory.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →