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 insrc/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, enforcesMAX_SESSIONSlimits, 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →