* fix: n8n_test_workflow webhookId resolution and form handling (v2.28.2) ## Bug Fixes - **webhookId Resolution**: Fixed trigger handlers using `node.id` instead of `node.webhookId` for building webhook URLs. This caused 404 errors when nodes had custom IDs. - **Chat Trigger URL**: Fixed chat triggers using wrong URL pattern. Now correctly uses `/webhook/<webhookId>/chat` endpoint. - **Form Content-Type**: Fixed form triggers failing with "Expected multipart/form-data" error by switching to proper multipart encoding. ## Enhancements - **Form Field Types**: Added support for all n8n form field types (text, textarea, email, number, password, date, dropdown, checkbox, file, hidden) - **Checkbox Arrays**: Automatically converts arrays to `field[]` format - **Helpful Warnings**: Reports missing required fields with names and labels - **Error Hints**: Provides complete field structure on failure Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address code review issues for form trigger improvements - Add form-data as direct dependency (was only in devDependencies) - Add TypeScript interfaces (FormFieldValue, FormFieldOption) replacing any types - Add FORM_FIELD_TYPES constants for type-safe switch statements - Add isValidBase64() validation for file uploads with size limits - Add MAX_FILE_SIZE_BYTES (10MB) constant with validation - Update form-handler.test.ts for FormData instead of JSON - Update trigger-detector.test.ts for chat URL /chat suffix Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: configurable MAX_SESSIONS via N8N_MCP_MAX_SESSIONS env var (v2.28.3) (#468) Make MAX_SESSIONS limit configurable for multi-tenant SaaS deployments. - Add N8N_MCP_MAX_SESSIONS environment variable (default: 100) - Include safety floor with Math.max(1, ...) to prevent invalid configs - Update documentation in LIBRARY_USAGE.md, SESSION_PERSISTENCE.md - Update CLAUDE.md and CHANGELOG.md Fixes #468 Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add QEMU setup for multi-arch Docker builds Add docker/setup-qemu-action@v3 before Buildx setup to enable proper QEMU emulation for linux/arm64 builds on GitHub Actions. Fixes CI Docker build failure with "execve: No such file or directory" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Romuald Członkowski <romualdczlonkowski@MacBook-Pro-Romuald.local>
19 KiB
Session Persistence API - Production Guide
Overview
The Session Persistence API enables zero-downtime container deployments in multi-tenant n8n-mcp environments. It allows you to export active MCP session state before shutdown and restore it after restart, maintaining session continuity across container lifecycle events.
Version: 2.24.1+ Status: Production-ready Use Cases: Multi-tenant SaaS, Kubernetes deployments, container orchestration, rolling updates
Architecture
Session State Components
Each persisted session contains:
-
Session Metadata
sessionId: Unique session identifier (UUID v4)createdAt: ISO 8601 timestamp of session creationlastAccess: ISO 8601 timestamp of last activity
-
Instance Context
n8nApiUrl: n8n instance API endpointn8nApiKey: n8n API authentication key (plaintext)instanceId: Optional tenant/instance identifiersessionId: Optional session-specific identifiermetadata: Optional custom application data
-
Dormant Session Pattern
- Transport and MCP server objects are NOT persisted
- Recreated automatically on first request after restore
- Reduces memory footprint during restore
API Reference
N8NMCPEngine.exportSessionState()
Exports all active session state for persistence before shutdown.
exportSessionState(): SessionState[]
Returns: Array of session state objects containing metadata and credentials
Example:
const sessions = engine.exportSessionState();
// sessions = [
// {
// sessionId: '550e8400-e29b-41d4-a716-446655440000',
// metadata: {
// createdAt: '2025-11-24T10:30:00.000Z',
// lastAccess: '2025-11-24T17:15:32.000Z'
// },
// context: {
// n8nApiUrl: 'https://tenant1.n8n.cloud',
// n8nApiKey: 'n8n_api_...',
// instanceId: 'tenant-123',
// metadata: { userId: 'user-456' }
// }
// }
// ]
Key Behaviors:
- Exports only non-expired sessions (within sessionTimeout)
- Detects and warns about duplicate session IDs
- Logs security event with session count
- Returns empty array if no active sessions
N8NMCPEngine.restoreSessionState()
Restores sessions from previously exported state after container restart.
restoreSessionState(sessions: SessionState[]): number
Parameters:
sessions: Array of session state objects fromexportSessionState()
Returns: Number of sessions successfully restored
Example:
const sessions = await loadFromEncryptedStorage();
const count = engine.restoreSessionState(sessions);
console.log(`Restored ${count} sessions`);
Key Behaviors:
- Validates session metadata (timestamps, required fields)
- Skips expired sessions (age > sessionTimeout)
- Skips duplicate sessions (idempotent)
- Respects MAX_SESSIONS limit (default 100, configurable via N8N_MCP_MAX_SESSIONS env var)
- Recreates transports/servers lazily on first request
- Logs security events for restore success/failure
Security Considerations
Critical: Encrypt Before Storage
The exported session state contains plaintext n8n API keys. You MUST encrypt this data before persisting to disk.
// ❌ NEVER DO THIS
await fs.writeFile('sessions.json', JSON.stringify(sessions));
// ✅ ALWAYS ENCRYPT
const encrypted = await encryptSessionData(sessions, encryptionKey);
await saveToSecureStorage(encrypted);
Recommended Encryption Approach
import crypto from 'crypto';
/**
* Encrypt session data using AES-256-GCM
*/
async function encryptSessionData(
sessions: SessionState[],
encryptionKey: Buffer
): Promise<string> {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv);
const json = JSON.stringify(sessions);
const encrypted = Buffer.concat([
cipher.update(json, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag();
// Return base64: iv:authTag:encrypted
return [
iv.toString('base64'),
authTag.toString('base64'),
encrypted.toString('base64')
].join(':');
}
/**
* Decrypt session data
*/
async function decryptSessionData(
encryptedData: string,
encryptionKey: Buffer
): Promise<SessionState[]> {
const [ivB64, authTagB64, encryptedB64] = encryptedData.split(':');
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const encrypted = Buffer.from(encryptedB64, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return JSON.parse(decrypted.toString('utf8'));
}
Key Management
Store encryption keys securely:
- Kubernetes: Use Kubernetes Secrets with encryption at rest
- AWS: Use AWS Secrets Manager or Parameter Store with KMS
- Azure: Use Azure Key Vault
- GCP: Use Secret Manager
- Local Dev: Use environment variables (NEVER commit to git)
Security Logging
All session persistence operations are logged with [SECURITY] prefix:
[SECURITY] session_export { timestamp, count }
[SECURITY] session_restore { timestamp, sessionId, instanceId }
[SECURITY] session_restore_failed { timestamp, sessionId, reason }
[SECURITY] max_sessions_reached { timestamp, count }
Monitor these logs in production for audit trails and security analysis.
Implementation Examples
1. Express.js Multi-Tenant Backend
import express from 'express';
import { N8NMCPEngine } from 'n8n-mcp';
const app = express();
const engine = new N8NMCPEngine({
sessionTimeout: 1800000, // 30 minutes
logLevel: 'info'
});
// Startup: Restore sessions from encrypted storage
async function startup() {
try {
const encrypted = await redis.get('mcp:sessions');
if (encrypted) {
const sessions = await decryptSessionData(
encrypted,
process.env.ENCRYPTION_KEY
);
const count = engine.restoreSessionState(sessions);
console.log(`Restored ${count} sessions`);
}
} catch (error) {
console.error('Failed to restore sessions:', error);
}
}
// Shutdown: Export sessions to encrypted storage
async function shutdown() {
try {
const sessions = engine.exportSessionState();
const encrypted = await encryptSessionData(
sessions,
process.env.ENCRYPTION_KEY
);
await redis.set('mcp:sessions', encrypted, 'EX', 3600); // 1 hour TTL
console.log(`Exported ${sessions.length} sessions`);
} catch (error) {
console.error('Failed to export sessions:', error);
}
await engine.shutdown();
process.exit(0);
}
// Handle graceful shutdown
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
// Start server
await startup();
app.listen(3000);
2. Kubernetes Deployment with Init Container
deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: n8n-mcp
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
spec:
initContainers:
- name: restore-sessions
image: your-app:latest
command: ['/app/restore-sessions.sh']
env:
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: mcp-secrets
key: encryption-key
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: mcp-secrets
key: redis-url
volumeMounts:
- name: sessions
mountPath: /sessions
containers:
- name: mcp-server
image: your-app:latest
lifecycle:
preStop:
exec:
command: ['/app/export-sessions.sh']
env:
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: mcp-secrets
key: encryption-key
- name: SESSION_TIMEOUT
value: "1800000"
volumeMounts:
- name: sessions
mountPath: /sessions
# Graceful shutdown configuration
terminationGracePeriodSeconds: 30
volumes:
- name: sessions
emptyDir: {}
restore-sessions.sh:
#!/bin/bash
set -e
echo "Restoring sessions from Redis..."
# Fetch encrypted sessions from Redis
ENCRYPTED=$(redis-cli -u "$REDIS_URL" GET "mcp:sessions:${HOSTNAME}")
if [ -n "$ENCRYPTED" ]; then
echo "$ENCRYPTED" > /sessions/encrypted.txt
echo "Sessions fetched, will be restored on startup"
else
echo "No sessions to restore"
fi
export-sessions.sh:
#!/bin/bash
set -e
echo "Exporting sessions to Redis..."
# Trigger session export via HTTP endpoint
curl -X POST http://localhost:3000/internal/export-sessions
echo "Sessions exported successfully"
3. Docker Compose with Redis
docker-compose.yml:
version: '3.8'
services:
n8n-mcp:
build: .
environment:
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- REDIS_URL=redis://redis:6379
- SESSION_TIMEOUT=1800000
depends_on:
- redis
volumes:
- ./data:/data
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
stop_grace_period: 30s
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
command: redis-server --appendonly yes
volumes:
redis-data:
Application code:
import { N8NMCPEngine } from 'n8n-mcp';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const engine = new N8NMCPEngine();
// Export endpoint (called by preStop hook)
app.post('/internal/export-sessions', async (req, res) => {
try {
const sessions = engine.exportSessionState();
const encrypted = await encryptSessionData(
sessions,
Buffer.from(process.env.ENCRYPTION_KEY, 'hex')
);
// Store with hostname as key for per-container tracking
await redis.set(
`mcp:sessions:${os.hostname()}`,
encrypted,
'EX',
3600
);
res.json({ exported: sessions.length });
} catch (error) {
console.error('Export failed:', error);
res.status(500).json({ error: 'Export failed' });
}
});
// Restore on startup
async function startup() {
const encrypted = await redis.get(`mcp:sessions:${os.hostname()}`);
if (encrypted) {
const sessions = await decryptSessionData(
encrypted,
Buffer.from(process.env.ENCRYPTION_KEY, 'hex')
);
const count = engine.restoreSessionState(sessions);
console.log(`Restored ${count} sessions`);
}
}
Best Practices
1. Session Timeout Configuration
Choose appropriate timeout based on use case:
const engine = new N8NMCPEngine({
sessionTimeout: 1800000 // 30 minutes (recommended default)
});
// Development: 5 minutes
sessionTimeout: 300000
// Production SaaS: 30-60 minutes
sessionTimeout: 1800000 - 3600000
// Long-running workflows: 2-4 hours
sessionTimeout: 7200000 - 14400000
2. Storage Backend Selection
Redis (Recommended for Production)
- Fast read/write for session data
- TTL support for automatic cleanup
- Pub/sub for distributed coordination
- Atomic operations for consistency
Database (PostgreSQL/MySQL)
- JSONB column for session state
- Good for audit requirements
- Slower than Redis
- Requires periodic cleanup
S3/Cloud Storage
- Good for disaster recovery backups
- Not suitable for hot session restore
- High latency
- Good for long-term session archival
3. Monitoring and Alerting
Monitor these metrics:
// Session export metrics
const sessions = engine.exportSessionState();
metrics.gauge('mcp.sessions.exported', sessions.length);
metrics.gauge('mcp.sessions.export_size_kb',
JSON.stringify(sessions).length / 1024
);
// Session restore metrics
const restored = engine.restoreSessionState(sessions);
metrics.gauge('mcp.sessions.restored', restored);
metrics.gauge('mcp.sessions.restore_success_rate',
restored / sessions.length
);
// Runtime metrics
const info = engine.getSessionInfo();
metrics.gauge('mcp.sessions.active', info.active ? 1 : 0);
metrics.gauge('mcp.sessions.age_seconds', info.age || 0);
Alert on:
- Export failures (should be rare)
- Low restore success rate (<95%)
- MAX_SESSIONS limit reached
- High session age (potential leaks)
4. Graceful Shutdown Timing
Ensure sufficient time for session export:
// Kubernetes terminationGracePeriodSeconds
terminationGracePeriodSeconds: 30 // 30 seconds minimum
// Docker stop timeout
docker run --stop-timeout 30 your-image
// Process signal handling
process.on('SIGTERM', async () => {
console.log('SIGTERM received, starting graceful shutdown...');
// 1. Stop accepting new requests (5s)
await server.close();
// 2. Wait for in-flight requests (10s)
await waitForInFlightRequests(10000);
// 3. Export sessions (5s)
const sessions = engine.exportSessionState();
await saveEncryptedSessions(sessions);
// 4. Cleanup (5s)
await engine.shutdown();
// 5. Exit (5s buffer)
process.exit(0);
});
5. Idempotency Handling
Sessions can be restored multiple times safely:
// First restore
const count1 = engine.restoreSessionState(sessions);
// count1 = 5
// Second restore (same sessions)
const count2 = engine.restoreSessionState(sessions);
// count2 = 0 (all already exist)
This is safe for:
- Init container retries
- Manual recovery operations
- Disaster recovery scenarios
6. Multi-Instance Coordination
For multiple container instances:
// Option 1: Per-instance storage (simple)
const key = `mcp:sessions:${instance.hostname}`;
// Option 2: Centralized with distributed lock (advanced)
const lock = await acquireLock('mcp:session-export');
try {
const allSessions = await getAllInstanceSessions();
await saveToBackup(allSessions);
} finally {
await lock.release();
}
Performance Considerations
Memory Usage
// Each session: ~1-2 KB in memory
// 100 sessions: ~100-200 KB
// 1000 sessions: ~1-2 MB
// Export serialized size
const sessions = engine.exportSessionState();
const sizeKB = JSON.stringify(sessions).length / 1024;
console.log(`Export size: ${sizeKB.toFixed(2)} KB`);
Export/Restore Speed
// Export: O(n) where n = active sessions
// Typical: 50-100 sessions in <10ms
// Restore: O(n) with validation
// Typical: 50-100 sessions in 20-50ms
// Factor in encryption:
// AES-256-GCM: ~1ms per 100 sessions
MAX_SESSIONS Limit
Default limit: 100 sessions per container (configurable via N8N_MCP_MAX_SESSIONS env var)
// Restore respects limit
const sessions = createSessions(150); // 150 sessions
const restored = engine.restoreSessionState(sessions);
// restored = 100 (only first 100 restored, or N8N_MCP_MAX_SESSIONS value)
For higher session limits:
- Set
N8N_MCP_MAX_SESSIONS=1000(or desired limit) - Monitor memory usage as sessions consume resources
- Alternatively, deploy multiple containers with session routing/sharding
Troubleshooting
Issue: No sessions restored
Symptoms:
Restored 0 sessions
Causes:
- All sessions expired (age > sessionTimeout)
- Invalid date format in metadata
- Missing required context fields
Debug:
const sessions = await loadFromEncryptedStorage();
console.log('Loaded sessions:', sessions.length);
// Check individual sessions
sessions.forEach((s, i) => {
const age = Date.now() - new Date(s.metadata.lastAccess).getTime();
console.log(`Session ${i}: age=${age}ms, expired=${age > sessionTimeout}`);
});
Issue: Restore fails with "invalid context"
Symptoms:
[SECURITY] session_restore_failed { sessionId: '...', reason: 'invalid context: ...' }
Causes:
- Missing n8nApiUrl or n8nApiKey
- Invalid URL format
- Corrupted session data
Fix:
// Validate before restore
const valid = sessions.filter(s => {
if (!s.context?.n8nApiUrl || !s.context?.n8nApiKey) {
console.warn(`Invalid session ${s.sessionId}: missing credentials`);
return false;
}
try {
new URL(s.context.n8nApiUrl); // Validate URL
return true;
} catch {
console.warn(`Invalid session ${s.sessionId}: malformed URL`);
return false;
}
});
const count = engine.restoreSessionState(valid);
Issue: MAX_SESSIONS limit hit
Symptoms:
Reached MAX_SESSIONS limit (100), skipping remaining sessions
Solutions:
- Increase limit: Set
N8N_MCP_MAX_SESSIONS=1000(or desired value) - Scale horizontally (more containers)
- Implement session sharding
- Reduce sessionTimeout
- Clean up inactive sessions
// Pre-filter by activity
const recentSessions = sessions.filter(s => {
const age = Date.now() - new Date(s.metadata.lastAccess).getTime();
return age < 600000; // Only restore sessions active in last 10 min
});
const count = engine.restoreSessionState(recentSessions);
Issue: Duplicate session IDs
Symptoms:
Duplicate sessionId detected during export: 550e8400-...
Cause: Bug in session management logic
Fix: This is a warning, not an error. The duplicate is automatically skipped. If persistent, investigate session creation logic.
Issue: High memory usage after restore
Symptoms: Container OOM after restoring many sessions
Cause: Too many sessions for container resources
Solution:
// Restore in batches
async function restoreInBatches(sessions: SessionState[], batchSize = 25) {
let totalRestored = 0;
for (let i = 0; i < sessions.length; i += batchSize) {
const batch = sessions.slice(i, i + batchSize);
const count = engine.restoreSessionState(batch);
totalRestored += count;
// Wait for GC between batches
await new Promise(resolve => setTimeout(resolve, 100));
}
return totalRestored;
}
Version Compatibility
| Feature | Version | Status |
|---|---|---|
| exportSessionState() | 2.3.0+ | Stable |
| restoreSessionState() | 2.3.0+ | Stable |
| Security logging | 2.24.1+ | Stable |
| Duplicate detection | 2.24.1+ | Stable |
| Race condition fix | 2.24.1+ | Stable |
| Date validation | 2.24.1+ | Stable |
| Optional instanceId | 2.24.1+ | Stable |
Additional Resources
- HTTP Deployment Guide - Multi-tenant HTTP server setup
- Library Usage Guide - Embedding n8n-mcp in your app
- Docker Guide - Container deployment
- Flexible Instance Configuration - Multi-tenant patterns
Support
For issues or questions:
- GitHub Issues: https://github.com/czlonkowski/n8n-mcp/issues
- Documentation: https://github.com/czlonkowski/n8n-mcp#readme
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en