diff --git a/CHANGELOG.md b/CHANGELOG.md index fe78f02..85f5f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.24.1] - 2025-01-24 + +### ✨ Features + +**Session Persistence API** + +Added export/restore functionality for MCP sessions to enable zero-downtime deployments in container environments (Kubernetes, Docker Swarm, etc.). + +#### What's New + +**1. Export Session State** +- `exportSessionState()` method in `SingleSessionHTTPServer` and `N8NMCPEngine` +- Exports all active sessions with metadata and instance context +- Automatically filters expired sessions +- Returns serializable `SessionState[]` array + +**2. Restore Session State** +- `restoreSessionState(sessions)` method for session recovery +- Validates session structure using existing `validateInstanceContext()` +- Handles null/invalid sessions gracefully with warnings +- Enforces MAX_SESSIONS limit (100 concurrent sessions) +- Skips expired sessions during restore + +**3. SessionState Type** +- New type definition in `src/types/session-state.ts` +- Fully documented with JSDoc comments +- Includes metadata (timestamps) and context (credentials) +- Exported from main package index + +**4. Dormant Session Behavior** +- Restored sessions are "dormant" until first request +- Transport and server objects recreated on-demand +- Memory-efficient session recovery + +#### Security Considerations + +⚠️ **IMPORTANT:** Exported session data contains plaintext n8n API keys. Downstream applications MUST encrypt session data before persisting to disk using AES-256-GCM or equivalent. + +#### Use Cases +- Zero-downtime deployments in container orchestration +- Session recovery after crashes or restarts +- Multi-tenant platform session management +- Rolling updates without user disruption + +#### Testing +- 22 comprehensive unit tests (100% passing) +- Tests cover export, restore, edge cases, and round-trip cycles +- Validation of expired session filtering and error handling + +#### Implementation Details +- Only exports sessions with valid `n8nApiUrl` and `n8nApiKey` in context +- Respects `sessionTimeout` setting (default 30 minutes) +- Session metadata and context persisted; transport/server recreated on-demand +- Comprehensive error handling with detailed logging + +**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)** + ## [2.24.0] - 2025-01-24 ### ✨ Features diff --git a/CLAUDE.md b/CLAUDE.md index a3ddb49..6e4dc35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,9 @@ src/ │ ├── expression-validator.ts # n8n expression syntax validation (NEW in v2.5.0) │ └── workflow-validator.ts # Complete workflow validation (NEW in v2.5.0) ├── types/ -│ └── type-structures.ts # Type structure definitions (NEW in v2.22.21) +│ ├── type-structures.ts # Type structure definitions (NEW in v2.22.21) +│ ├── instance-context.ts # Multi-tenant instance configuration +│ └── session-state.ts # Session persistence types (NEW in v2.24.1) ├── constants/ │ └── type-structures.ts # 22 complete type structures (NEW in v2.22.21) ├── templates/ @@ -64,7 +66,9 @@ src/ │ ├── console-manager.ts # Console output isolation (NEW in v2.3.1) │ └── logger.ts # Logging utility with HTTP awareness ├── http-server-single-session.ts # Single-session HTTP server (NEW in v2.3.1) +│ # Session persistence API (NEW in v2.24.1) ├── mcp-engine.ts # Clean API for service integration (NEW in v2.3.1) +│ # Session persistence wrappers (NEW in v2.24.1) └── index.ts # Library exports ``` @@ -191,6 +195,35 @@ The MCP server exposes tools in several categories: ### Development Best Practices - Run typecheck and lint after every code change +### Session Persistence Feature (v2.24.1) + +**Location:** +- Types: `src/types/session-state.ts` +- Implementation: `src/http-server-single-session.ts` (lines 698-702, 1444-1584) +- Wrapper: `src/mcp-engine.ts` (lines 123-169) +- Tests: `tests/unit/http-server/session-persistence.test.ts`, `tests/unit/mcp-engine/session-persistence.test.ts` + +**Key Features:** +- **Export/Restore API**: `exportSessionState()` and `restoreSessionState()` methods +- **Multi-tenant support**: Enables zero-downtime deployments for SaaS platforms +- **Security-first**: API keys exported as plaintext - downstream MUST encrypt +- **Dormant sessions**: Restored sessions recreate transports on first request +- **Automatic expiration**: Respects `sessionTimeout` setting (default 30 min) +- **MAX_SESSIONS limit**: Caps at 100 concurrent sessions + +**Important Implementation Notes:** +- Only exports sessions with valid n8nApiUrl and n8nApiKey in context +- Skips expired sessions during both export and restore +- Uses `validateInstanceContext()` for data integrity checks +- Handles null/invalid session gracefully with warnings +- Session metadata (timestamps) and context (credentials) are persisted +- Transport and server objects are NOT persisted (recreated on-demand) + +**Testing:** +- 22 unit tests covering export, restore, edge cases, and round-trip cycles +- Tests use current timestamps to avoid expiration issues +- Integration with multi-tenant backends documented in README.md + # important-instruction-reminders Do what has been asked; nothing more, nothing less. NEVER create files unless they're absolutely necessary for achieving your goal. diff --git a/package.json b/package.json index 0b102ac..f4384ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.24.0", + "version": "2.24.1", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/http-server-single-session.ts b/src/http-server-single-session.ts index afd67b5..7400fc2 100644 --- a/src/http-server-single-session.ts +++ b/src/http-server-single-session.ts @@ -25,6 +25,7 @@ import { STANDARD_PROTOCOL_VERSION } from './utils/protocol-version'; import { InstanceContext, validateInstanceContext } from './types/instance-context'; +import { SessionState } from './types/session-state'; dotenv.config(); @@ -71,6 +72,30 @@ function extractMultiTenantHeaders(req: express.Request): MultiTenantHeaders { }; } +/** + * Security logging helper for audit trails + * Provides structured logging for security-relevant events + */ +function logSecurityEvent( + event: 'session_export' | 'session_restore' | 'session_restore_failed' | 'max_sessions_reached', + details: { + sessionId?: string; + reason?: string; + count?: number; + instanceId?: string; + } +): void { + const timestamp = new Date().toISOString(); + const logEntry = { + timestamp, + event, + ...details + }; + + // Log to standard logger with [SECURITY] prefix for easy filtering + logger.info(`[SECURITY] ${event}`, logEntry); +} + export class SingleSessionHTTPServer { // Map to store transports by session ID (following SDK pattern) private transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; @@ -687,7 +712,20 @@ export class SingleSessionHTTPServer { if (!this.session) return true; return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout; } - + + /** + * Check if a specific session is expired based on sessionId + * Used for multi-session expiration checks during export/restore + * + * @param sessionId - The session ID to check + * @returns true if session is expired or doesn't exist + */ + private isSessionExpired(sessionId: string): boolean { + const metadata = this.sessionMetadata[sessionId]; + if (!metadata) return true; + return Date.now() - metadata.lastAccess.getTime() > this.sessionTimeout; + } + /** * Start the HTTP server */ @@ -1406,6 +1444,197 @@ export class SingleSessionHTTPServer { } }; } + + /** + * Export all active session state for persistence + * + * Used by multi-tenant backends to dump sessions before container restart. + * This method exports the minimal state needed to restore sessions after + * a restart: session metadata (timing) and instance context (credentials). + * + * Transport and server objects are NOT persisted - they will be recreated + * on the first request after restore. + * + * SECURITY WARNING: The exported data contains plaintext n8n API keys. + * The downstream application MUST encrypt this data before persisting to disk. + * + * @returns Array of session state objects, excluding expired sessions + * + * @example + * // Before shutdown + * const sessions = server.exportSessionState(); + * await saveToEncryptedStorage(sessions); + */ + public exportSessionState(): SessionState[] { + const sessions: SessionState[] = []; + const seenSessionIds = new Set(); + + // Iterate over all sessions with metadata (source of truth for active sessions) + for (const sessionId of Object.keys(this.sessionMetadata)) { + // Check for duplicates (defensive programming) + if (seenSessionIds.has(sessionId)) { + logger.warn(`Duplicate sessionId detected during export: ${sessionId}`); + continue; + } + + // Skip expired sessions - they're not worth persisting + if (this.isSessionExpired(sessionId)) { + continue; + } + + const metadata = this.sessionMetadata[sessionId]; + const context = this.sessionContexts[sessionId]; + + // Skip sessions without context - these can't be restored meaningfully + // (Context is required to reconnect to the correct n8n instance) + if (!context || !context.n8nApiUrl || !context.n8nApiKey) { + logger.debug(`Skipping session ${sessionId} - missing required context`); + 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, // Use sessionId as fallback + sessionId: context.sessionId, + metadata: context.metadata + } + }); + } + + logger.info(`Exported ${sessions.length} session(s) for persistence`); + logSecurityEvent('session_export', { count: sessions.length }); + return sessions; + } + + /** + * Restore session state from previously exported data + * + * Used by multi-tenant backends to restore sessions after container restart. + * This method restores only the session metadata and instance context. + * Transport and server objects will be recreated on the first request. + * + * Restored sessions are "dormant" until a client makes a request, at which + * point the transport and server will be initialized normally. + * + * @param sessions - Array of session state objects from exportSessionState() + * @returns Number of sessions successfully restored + * + * @example + * // After startup + * const sessions = await loadFromEncryptedStorage(); + * const count = server.restoreSessionState(sessions); + * console.log(`Restored ${count} sessions`); + */ + public restoreSessionState(sessions: SessionState[]): number { + let restoredCount = 0; + + for (const sessionState of sessions) { + try { + // Skip null or invalid session objects + if (!sessionState || typeof sessionState !== 'object' || !sessionState.sessionId) { + logger.warn('Skipping invalid session state object'); + continue; + } + + // Check if we've hit the MAX_SESSIONS limit (check real-time count) + if (Object.keys(this.sessionMetadata).length >= MAX_SESSIONS) { + logger.warn( + `Reached MAX_SESSIONS limit (${MAX_SESSIONS}), skipping remaining sessions` + ); + logSecurityEvent('max_sessions_reached', { count: MAX_SESSIONS }); + break; + } + + // Skip if session already exists (duplicate sessionId) + if (this.sessionMetadata[sessionState.sessionId]) { + logger.debug(`Skipping session ${sessionState.sessionId} - already exists`); + continue; + } + + // Parse and validate dates first + const createdAt = new Date(sessionState.metadata.createdAt); + const lastAccess = new Date(sessionState.metadata.lastAccess); + + if (isNaN(createdAt.getTime()) || isNaN(lastAccess.getTime())) { + logger.warn( + `Skipping session ${sessionState.sessionId} - invalid date format` + ); + continue; + } + + // Validate session isn't expired + const age = Date.now() - lastAccess.getTime(); + if (age > this.sessionTimeout) { + logger.debug( + `Skipping session ${sessionState.sessionId} - expired (age: ${Math.round(age / 1000)}s)` + ); + continue; + } + + // Validate context exists (TypeScript null narrowing) + if (!sessionState.context) { + logger.warn(`Skipping session ${sessionState.sessionId} - missing context`); + continue; + } + + // Validate context structure using existing validation + const validation = validateInstanceContext(sessionState.context); + if (!validation.valid) { + const reason = validation.errors?.join(', ') || 'invalid context'; + logger.warn( + `Skipping session ${sessionState.sessionId} - invalid context: ${reason}` + ); + logSecurityEvent('session_restore_failed', { + sessionId: sessionState.sessionId, + reason + }); + continue; + } + + // Restore session metadata + this.sessionMetadata[sessionState.sessionId] = { + createdAt, + lastAccess + }; + + // Restore session context + this.sessionContexts[sessionState.sessionId] = { + n8nApiUrl: sessionState.context.n8nApiUrl, + n8nApiKey: sessionState.context.n8nApiKey, + instanceId: sessionState.context.instanceId, + sessionId: sessionState.context.sessionId, + metadata: sessionState.context.metadata + }; + + logger.debug(`Restored session ${sessionState.sessionId}`); + logSecurityEvent('session_restore', { + sessionId: sessionState.sessionId, + instanceId: sessionState.context.instanceId + }); + restoredCount++; + } catch (error) { + logger.error(`Failed to restore session ${sessionState.sessionId}:`, error); + logSecurityEvent('session_restore_failed', { + sessionId: sessionState.sessionId, + reason: error instanceof Error ? error.message : 'unknown error' + }); + // Continue with next session - don't let one failure break the entire restore + } + } + + logger.info( + `Restored ${restoredCount}/${sessions.length} session(s) from persistence` + ); + return restoredCount; + } } // Start if called directly diff --git a/src/index.ts b/src/index.ts index b5c1005..0dd3852 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,9 @@ export { validateInstanceContext, isInstanceContext } from './types/instance-context'; +export type { + SessionState +} from './types/session-state'; // Re-export MCP SDK types for convenience export type { diff --git a/src/mcp-engine.ts b/src/mcp-engine.ts index d1a6632..e5cc54b 100644 --- a/src/mcp-engine.ts +++ b/src/mcp-engine.ts @@ -9,6 +9,7 @@ import { Request, Response } from 'express'; import { SingleSessionHTTPServer } from './http-server-single-session'; import { logger } from './utils/logger'; import { InstanceContext } from './types/instance-context'; +import { SessionState } from './types/session-state'; export interface EngineHealth { status: 'healthy' | 'unhealthy'; @@ -97,7 +98,7 @@ export class N8NMCPEngine { total: Math.round(memoryUsage.heapTotal / 1024 / 1024), unit: 'MB' }, - version: '2.3.2' + version: '2.24.1' }; } catch (error) { logger.error('Health check failed:', error); @@ -106,7 +107,7 @@ export class N8NMCPEngine { uptime: 0, sessionActive: false, memoryUsage: { used: 0, total: 0, unit: 'MB' }, - version: '2.3.2' + version: '2.24.1' }; } } @@ -118,10 +119,58 @@ export class N8NMCPEngine { getSessionInfo(): { active: boolean; sessionId?: string; age?: number } { return this.server.getSessionInfo(); } - + + /** + * Export all active session state for persistence + * + * Used by multi-tenant backends to dump sessions before container restart. + * Returns an array of session state objects containing metadata and credentials. + * + * SECURITY WARNING: Exported data contains plaintext n8n API keys. + * Encrypt before persisting to disk. + * + * @returns Array of session state objects + * + * @example + * // Before shutdown + * const sessions = engine.exportSessionState(); + * await saveToEncryptedStorage(sessions); + */ + exportSessionState(): SessionState[] { + if (!this.server) { + logger.warn('Cannot export sessions: server not initialized'); + return []; + } + return this.server.exportSessionState(); + } + + /** + * Restore session state from previously exported data + * + * Used by multi-tenant backends to restore sessions after container restart. + * Restores session metadata and instance context. Transports/servers are + * recreated on first request. + * + * @param sessions - Array of session state objects from exportSessionState() + * @returns Number of sessions successfully restored + * + * @example + * // After startup + * const sessions = await loadFromEncryptedStorage(); + * const count = engine.restoreSessionState(sessions); + * console.log(`Restored ${count} sessions`); + */ + restoreSessionState(sessions: SessionState[]): number { + if (!this.server) { + logger.warn('Cannot restore sessions: server not initialized'); + return 0; + } + return this.server.restoreSessionState(sessions); + } + /** * Graceful shutdown for service lifecycle - * + * * @example * process.on('SIGTERM', async () => { * await engine.shutdown(); diff --git a/src/types/index.ts b/src/types/index.ts index d15d627..ccddfbf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,8 @@ // Export n8n node type definitions and utilities export * from './node-types'; export * from './type-structures'; +export * from './instance-context'; +export * from './session-state'; export interface MCPServerConfig { port: number; diff --git a/src/types/session-state.ts b/src/types/session-state.ts new file mode 100644 index 0000000..a86b282 --- /dev/null +++ b/src/types/session-state.ts @@ -0,0 +1,92 @@ +/** + * Session persistence types for multi-tenant deployments + * + * These types support exporting and restoring MCP session state across + * container restarts, enabling seamless session persistence in production. + */ + +import { InstanceContext } from './instance-context.js'; + +/** + * Serializable session state for persistence across restarts + * + * This interface represents the minimal state needed to restore an MCP session + * after a container restart. Only the session metadata and instance context are + * persisted - transport and server objects are recreated on the first request. + * + * @example + * // Export sessions before shutdown + * const sessions = server.exportSessionState(); + * await saveToEncryptedStorage(sessions); + * + * @example + * // Restore sessions on startup + * const sessions = await loadFromEncryptedStorage(); + * const count = server.restoreSessionState(sessions); + * console.log(`Restored ${count} sessions`); + */ +export interface SessionState { + /** + * Unique session identifier + * Format: UUID v4 or custom format from MCP proxy + */ + sessionId: string; + + /** + * Session timing metadata for expiration tracking + */ + metadata: { + /** + * When the session was created (ISO 8601 timestamp) + * Used to track total session age + */ + createdAt: string; + + /** + * When the session was last accessed (ISO 8601 timestamp) + * Used to determine if session has expired based on timeout + */ + lastAccess: string; + }; + + /** + * n8n instance context (credentials and configuration) + * + * Contains the n8n API credentials and instance-specific settings. + * This is the critical data needed to reconnect to the correct n8n instance. + * + * Note: API keys are stored in plaintext. The downstream application + * MUST encrypt this data before persisting to disk. + */ + context: { + /** + * n8n instance API URL + * Example: "https://n8n.example.com" + */ + n8nApiUrl: string; + + /** + * n8n instance API key (plaintext - encrypt before storage!) + * Example: "n8n_api_1234567890abcdef" + */ + n8nApiKey: string; + + /** + * Instance identifier (optional) + * Custom identifier for tracking which n8n instance this session belongs to + */ + instanceId?: string; + + /** + * Session-specific ID (optional) + * May differ from top-level sessionId in some proxy configurations + */ + sessionId?: string; + + /** + * Additional metadata (optional) + * Extensible field for custom application data + */ + metadata?: Record; + }; +} diff --git a/tests/unit/http-server/session-persistence.test.ts b/tests/unit/http-server/session-persistence.test.ts new file mode 100644 index 0000000..e7ff177 --- /dev/null +++ b/tests/unit/http-server/session-persistence.test.ts @@ -0,0 +1,546 @@ +/** + * Unit tests for session persistence API + * Tests export and restore functionality for multi-tenant session management + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SingleSessionHTTPServer } from '../../../src/http-server-single-session'; +import { SessionState } from '../../../src/types/session-state'; + +describe('SingleSessionHTTPServer - Session Persistence', () => { + let server: SingleSessionHTTPServer; + + beforeEach(() => { + server = new SingleSessionHTTPServer(); + }); + + describe('exportSessionState()', () => { + it('should return empty array when no sessions exist', () => { + const exported = server.exportSessionState(); + expect(exported).toEqual([]); + }); + + it('should export active sessions with all required fields', () => { + // Create mock sessions by directly manipulating internal state + const sessionId1 = 'test-session-1'; + const sessionId2 = 'test-session-2'; + + // Use current timestamps to avoid expiration + const now = new Date(); + const createdAt1 = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago + const lastAccess1 = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago + const createdAt2 = new Date(now.getTime() - 15 * 60 * 1000); // 15 minutes ago + const lastAccess2 = new Date(now.getTime() - 3 * 60 * 1000); // 3 minutes ago + + // Access private properties for testing + const serverAny = server as any; + + serverAny.sessionMetadata[sessionId1] = { + createdAt: createdAt1, + lastAccess: lastAccess1 + }; + + serverAny.sessionContexts[sessionId1] = { + n8nApiUrl: 'https://n8n1.example.com', + n8nApiKey: 'key1', + instanceId: 'instance1', + sessionId: sessionId1, + metadata: { userId: 'user1' } + }; + + serverAny.sessionMetadata[sessionId2] = { + createdAt: createdAt2, + lastAccess: lastAccess2 + }; + + serverAny.sessionContexts[sessionId2] = { + n8nApiUrl: 'https://n8n2.example.com', + n8nApiKey: 'key2', + instanceId: 'instance2' + }; + + const exported = server.exportSessionState(); + + expect(exported).toHaveLength(2); + + // Verify first session + expect(exported[0]).toMatchObject({ + sessionId: sessionId1, + metadata: { + createdAt: createdAt1.toISOString(), + lastAccess: lastAccess1.toISOString() + }, + context: { + n8nApiUrl: 'https://n8n1.example.com', + n8nApiKey: 'key1', + instanceId: 'instance1', + sessionId: sessionId1, + metadata: { userId: 'user1' } + } + }); + + // Verify second session + expect(exported[1]).toMatchObject({ + sessionId: sessionId2, + metadata: { + createdAt: createdAt2.toISOString(), + lastAccess: lastAccess2.toISOString() + }, + context: { + n8nApiUrl: 'https://n8n2.example.com', + n8nApiKey: 'key2', + instanceId: 'instance2' + } + }); + }); + + it('should skip expired sessions during export', () => { + const serverAny = server as any; + const now = Date.now(); + const sessionTimeout = 30 * 60 * 1000; // 30 minutes (default) + + // Create an active session (accessed recently) + serverAny.sessionMetadata['active-session'] = { + createdAt: new Date(now - 10 * 60 * 1000), // 10 minutes ago + lastAccess: new Date(now - 5 * 60 * 1000) // 5 minutes ago + }; + serverAny.sessionContexts['active-session'] = { + n8nApiUrl: 'https://active.example.com', + n8nApiKey: 'active-key', + instanceId: 'active-instance' + }; + + // Create an expired session (last accessed > 30 minutes ago) + serverAny.sessionMetadata['expired-session'] = { + createdAt: new Date(now - 60 * 60 * 1000), // 60 minutes ago + lastAccess: new Date(now - 45 * 60 * 1000) // 45 minutes ago (expired) + }; + serverAny.sessionContexts['expired-session'] = { + n8nApiUrl: 'https://expired.example.com', + n8nApiKey: 'expired-key', + instanceId: 'expired-instance' + }; + + const exported = server.exportSessionState(); + + expect(exported).toHaveLength(1); + expect(exported[0].sessionId).toBe('active-session'); + }); + + it('should skip sessions without required context fields', () => { + const serverAny = server as any; + + // Session with complete context + serverAny.sessionMetadata['complete-session'] = { + createdAt: new Date(), + lastAccess: new Date() + }; + serverAny.sessionContexts['complete-session'] = { + n8nApiUrl: 'https://complete.example.com', + n8nApiKey: 'complete-key', + instanceId: 'complete-instance' + }; + + // Session with missing n8nApiUrl + serverAny.sessionMetadata['missing-url'] = { + createdAt: new Date(), + lastAccess: new Date() + }; + serverAny.sessionContexts['missing-url'] = { + n8nApiKey: 'key', + instanceId: 'instance' + }; + + // Session with missing n8nApiKey + serverAny.sessionMetadata['missing-key'] = { + createdAt: new Date(), + lastAccess: new Date() + }; + serverAny.sessionContexts['missing-key'] = { + n8nApiUrl: 'https://example.com', + instanceId: 'instance' + }; + + // Session with no context at all + serverAny.sessionMetadata['no-context'] = { + createdAt: new Date(), + lastAccess: new Date() + }; + + const exported = server.exportSessionState(); + + expect(exported).toHaveLength(1); + expect(exported[0].sessionId).toBe('complete-session'); + }); + + it('should use sessionId as fallback for instanceId', () => { + const serverAny = server as any; + const sessionId = 'test-session'; + + serverAny.sessionMetadata[sessionId] = { + createdAt: new Date(), + lastAccess: new Date() + }; + serverAny.sessionContexts[sessionId] = { + n8nApiUrl: 'https://example.com', + n8nApiKey: 'key' + // No instanceId provided + }; + + const exported = server.exportSessionState(); + + expect(exported).toHaveLength(1); + expect(exported[0].context.instanceId).toBe(sessionId); + }); + }); + + describe('restoreSessionState()', () => { + it('should restore valid sessions correctly', () => { + const sessions: SessionState[] = [ + { + sessionId: 'restored-session-1', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://restored1.example.com', + n8nApiKey: 'restored-key-1', + instanceId: 'restored-instance-1' + } + }, + { + sessionId: 'restored-session-2', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://restored2.example.com', + n8nApiKey: 'restored-key-2', + instanceId: 'restored-instance-2', + sessionId: 'custom-session-id', + metadata: { custom: 'data' } + } + } + ]; + + const count = server.restoreSessionState(sessions); + + expect(count).toBe(2); + + // Verify sessions were restored by checking internal state + const serverAny = server as any; + + expect(serverAny.sessionMetadata['restored-session-1']).toBeDefined(); + expect(serverAny.sessionContexts['restored-session-1']).toMatchObject({ + n8nApiUrl: 'https://restored1.example.com', + n8nApiKey: 'restored-key-1', + instanceId: 'restored-instance-1' + }); + + expect(serverAny.sessionMetadata['restored-session-2']).toBeDefined(); + expect(serverAny.sessionContexts['restored-session-2']).toMatchObject({ + n8nApiUrl: 'https://restored2.example.com', + n8nApiKey: 'restored-key-2', + instanceId: 'restored-instance-2', + sessionId: 'custom-session-id', + metadata: { custom: 'data' } + }); + }); + + it('should skip expired sessions during restore', () => { + const now = Date.now(); + const sessionTimeout = 30 * 60 * 1000; // 30 minutes + + const sessions: SessionState[] = [ + { + sessionId: 'active-session', + metadata: { + createdAt: new Date(now - 10 * 60 * 1000).toISOString(), + lastAccess: new Date(now - 5 * 60 * 1000).toISOString() + }, + context: { + n8nApiUrl: 'https://active.example.com', + n8nApiKey: 'active-key', + instanceId: 'active-instance' + } + }, + { + sessionId: 'expired-session', + metadata: { + createdAt: new Date(now - 60 * 60 * 1000).toISOString(), + lastAccess: new Date(now - 45 * 60 * 1000).toISOString() // Expired + }, + context: { + n8nApiUrl: 'https://expired.example.com', + n8nApiKey: 'expired-key', + instanceId: 'expired-instance' + } + } + ]; + + const count = server.restoreSessionState(sessions); + + expect(count).toBe(1); + + const serverAny = server as any; + expect(serverAny.sessionMetadata['active-session']).toBeDefined(); + expect(serverAny.sessionMetadata['expired-session']).toBeUndefined(); + }); + + it('should skip sessions with missing required context fields', () => { + const sessions: SessionState[] = [ + { + sessionId: 'valid-session', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://valid.example.com', + n8nApiKey: 'valid-key', + instanceId: 'valid-instance' + } + }, + { + sessionId: 'missing-url', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: '', // Empty URL + n8nApiKey: 'key', + instanceId: 'instance' + } + }, + { + sessionId: 'missing-key', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://example.com', + n8nApiKey: '', // Empty key + instanceId: 'instance' + } + } + ]; + + const count = server.restoreSessionState(sessions); + + expect(count).toBe(1); + + const serverAny = server as any; + expect(serverAny.sessionMetadata['valid-session']).toBeDefined(); + expect(serverAny.sessionMetadata['missing-url']).toBeUndefined(); + expect(serverAny.sessionMetadata['missing-key']).toBeUndefined(); + }); + + it('should skip duplicate sessionIds', () => { + const serverAny = server as any; + + // Create an existing session + serverAny.sessionMetadata['existing-session'] = { + createdAt: new Date(), + lastAccess: new Date() + }; + + const sessions: SessionState[] = [ + { + sessionId: 'new-session', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://new.example.com', + n8nApiKey: 'new-key', + instanceId: 'new-instance' + } + }, + { + sessionId: 'existing-session', // Duplicate + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://duplicate.example.com', + n8nApiKey: 'duplicate-key', + instanceId: 'duplicate-instance' + } + } + ]; + + const count = server.restoreSessionState(sessions); + + expect(count).toBe(1); + expect(serverAny.sessionMetadata['new-session']).toBeDefined(); + }); + + it('should handle restore failures gracefully', () => { + const sessions: any[] = [ + { + sessionId: 'valid-session', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://valid.example.com', + n8nApiKey: 'valid-key', + instanceId: 'valid-instance' + } + }, + { + sessionId: 'bad-session', + metadata: {}, // Missing required fields + context: null // Invalid context + }, + null, // Invalid session + { + // Missing sessionId + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://example.com', + n8nApiKey: 'key', + instanceId: 'instance' + } + } + ]; + + // Should not throw and should restore only the valid session + expect(() => { + const count = server.restoreSessionState(sessions); + expect(count).toBe(1); // Only valid-session should be restored + }).not.toThrow(); + + // Verify the valid session was restored + const serverAny = server as any; + expect(serverAny.sessionMetadata['valid-session']).toBeDefined(); + }); + + it('should respect MAX_SESSIONS limit during restore', () => { + // Create 99 existing sessions (MAX_SESSIONS is 100) + const serverAny = server as any; + const now = new Date(); + for (let i = 0; i < 99; i++) { + serverAny.sessionMetadata[`existing-${i}`] = { + createdAt: now, + lastAccess: now + }; + } + + // Try to restore 3 sessions (should only restore 1 due to limit) + const sessions: SessionState[] = []; + for (let i = 0; i < 3; i++) { + sessions.push({ + sessionId: `new-session-${i}`, + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: `https://new${i}.example.com`, + n8nApiKey: `new-key-${i}`, + instanceId: `new-instance-${i}` + } + }); + } + + const count = server.restoreSessionState(sessions); + + expect(count).toBe(1); + expect(serverAny.sessionMetadata['new-session-0']).toBeDefined(); + expect(serverAny.sessionMetadata['new-session-1']).toBeUndefined(); + expect(serverAny.sessionMetadata['new-session-2']).toBeUndefined(); + }); + + it('should parse ISO 8601 timestamps correctly', () => { + // Use current timestamps to avoid expiration + const now = new Date(); + const createdAtDate = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago + const lastAccessDate = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago + const createdAt = createdAtDate.toISOString(); + const lastAccess = lastAccessDate.toISOString(); + + const sessions: SessionState[] = [ + { + sessionId: 'timestamp-session', + metadata: { createdAt, lastAccess }, + context: { + n8nApiUrl: 'https://example.com', + n8nApiKey: 'key', + instanceId: 'instance' + } + } + ]; + + const count = server.restoreSessionState(sessions); + expect(count).toBe(1); + + const serverAny = server as any; + const metadata = serverAny.sessionMetadata['timestamp-session']; + + expect(metadata.createdAt).toBeInstanceOf(Date); + expect(metadata.lastAccess).toBeInstanceOf(Date); + expect(metadata.createdAt.toISOString()).toBe(createdAt); + expect(metadata.lastAccess.toISOString()).toBe(lastAccess); + }); + }); + + describe('Round-trip export and restore', () => { + it('should preserve data through export → restore cycle', () => { + // Create sessions with current timestamps + const serverAny = server as any; + const now = new Date(); + const createdAt = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago + const lastAccess = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago + + serverAny.sessionMetadata['session-1'] = { + createdAt, + lastAccess + }; + serverAny.sessionContexts['session-1'] = { + n8nApiUrl: 'https://n8n1.example.com', + n8nApiKey: 'key1', + instanceId: 'instance1', + sessionId: 'custom-id-1', + metadata: { userId: 'user1', role: 'admin' } + }; + + // Export sessions + const exported = server.exportSessionState(); + expect(exported).toHaveLength(1); + + // Clear sessions + delete serverAny.sessionMetadata['session-1']; + delete serverAny.sessionContexts['session-1']; + + // Restore sessions + const count = server.restoreSessionState(exported); + expect(count).toBe(1); + + // Verify data integrity + const metadata = serverAny.sessionMetadata['session-1']; + const context = serverAny.sessionContexts['session-1']; + + expect(metadata.createdAt.toISOString()).toBe(createdAt.toISOString()); + expect(metadata.lastAccess.toISOString()).toBe(lastAccess.toISOString()); + + expect(context).toMatchObject({ + n8nApiUrl: 'https://n8n1.example.com', + n8nApiKey: 'key1', + instanceId: 'instance1', + sessionId: 'custom-id-1', + metadata: { userId: 'user1', role: 'admin' } + }); + }); + }); +}); diff --git a/tests/unit/mcp-engine/session-persistence.test.ts b/tests/unit/mcp-engine/session-persistence.test.ts new file mode 100644 index 0000000..cf1f76b --- /dev/null +++ b/tests/unit/mcp-engine/session-persistence.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for N8NMCPEngine session persistence wrapper methods + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { N8NMCPEngine } from '../../../src/mcp-engine'; +import { SessionState } from '../../../src/types/session-state'; + +describe('N8NMCPEngine - Session Persistence', () => { + let engine: N8NMCPEngine; + + beforeEach(() => { + engine = new N8NMCPEngine({ + sessionTimeout: 30 * 60 * 1000, + logLevel: 'error' // Quiet during tests + }); + }); + + describe('exportSessionState()', () => { + it('should return empty array when no sessions exist', () => { + const exported = engine.exportSessionState(); + expect(exported).toEqual([]); + }); + + it('should delegate to underlying server', () => { + // Access private server to create test sessions + const engineAny = engine as any; + const server = engineAny.server; + const serverAny = server as any; + + // Create a mock session + serverAny.sessionMetadata['test-session'] = { + createdAt: new Date(), + lastAccess: new Date() + }; + serverAny.sessionContexts['test-session'] = { + n8nApiUrl: 'https://test.example.com', + n8nApiKey: 'test-key', + instanceId: 'test-instance' + }; + + const exported = engine.exportSessionState(); + + expect(exported).toHaveLength(1); + expect(exported[0].sessionId).toBe('test-session'); + expect(exported[0].context.n8nApiUrl).toBe('https://test.example.com'); + }); + + it('should handle server not initialized', () => { + // Create engine without server + const engineAny = {} as N8NMCPEngine; + const exportMethod = N8NMCPEngine.prototype.exportSessionState.bind(engineAny); + + // Should not throw, should return empty array + expect(() => exportMethod()).not.toThrow(); + const result = exportMethod(); + expect(result).toEqual([]); + }); + }); + + describe('restoreSessionState()', () => { + it('should restore sessions via underlying server', () => { + const sessions: SessionState[] = [ + { + sessionId: 'restored-session', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://restored.example.com', + n8nApiKey: 'restored-key', + instanceId: 'restored-instance' + } + } + ]; + + const count = engine.restoreSessionState(sessions); + + expect(count).toBe(1); + + // Verify session was restored + const engineAny = engine as any; + const server = engineAny.server; + const serverAny = server as any; + + expect(serverAny.sessionMetadata['restored-session']).toBeDefined(); + expect(serverAny.sessionContexts['restored-session']).toMatchObject({ + n8nApiUrl: 'https://restored.example.com', + n8nApiKey: 'restored-key', + instanceId: 'restored-instance' + }); + }); + + it('should return 0 when restoring empty array', () => { + const count = engine.restoreSessionState([]); + expect(count).toBe(0); + }); + + it('should handle server not initialized', () => { + const engineAny = {} as N8NMCPEngine; + const restoreMethod = N8NMCPEngine.prototype.restoreSessionState.bind(engineAny); + + const sessions: SessionState[] = [ + { + sessionId: 'test', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://test.example.com', + n8nApiKey: 'test-key', + instanceId: 'test-instance' + } + } + ]; + + // Should not throw, should return 0 + expect(() => restoreMethod(sessions)).not.toThrow(); + const result = restoreMethod(sessions); + expect(result).toBe(0); + }); + + it('should return count of successfully restored sessions', () => { + const now = Date.now(); + const sessions: SessionState[] = [ + { + sessionId: 'valid-1', + metadata: { + createdAt: new Date(now - 10 * 60 * 1000).toISOString(), + lastAccess: new Date(now - 5 * 60 * 1000).toISOString() + }, + context: { + n8nApiUrl: 'https://valid1.example.com', + n8nApiKey: 'key1', + instanceId: 'instance1' + } + }, + { + sessionId: 'valid-2', + metadata: { + createdAt: new Date(now - 10 * 60 * 1000).toISOString(), + lastAccess: new Date(now - 5 * 60 * 1000).toISOString() + }, + context: { + n8nApiUrl: 'https://valid2.example.com', + n8nApiKey: 'key2', + instanceId: 'instance2' + } + }, + { + sessionId: 'expired', + metadata: { + createdAt: new Date(now - 60 * 60 * 1000).toISOString(), + lastAccess: new Date(now - 45 * 60 * 1000).toISOString() // Expired + }, + context: { + n8nApiUrl: 'https://expired.example.com', + n8nApiKey: 'expired-key', + instanceId: 'expired-instance' + } + } + ]; + + const count = engine.restoreSessionState(sessions); + + expect(count).toBe(2); // Only 2 valid sessions + }); + }); + + describe('Round-trip through engine', () => { + it('should preserve sessions through export → restore cycle', () => { + // Create mock sessions with current timestamps + const engineAny = engine as any; + const server = engineAny.server; + const serverAny = server as any; + + const now = new Date(); + const createdAt = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago + const lastAccess = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago + + serverAny.sessionMetadata['engine-session'] = { + createdAt, + lastAccess + }; + serverAny.sessionContexts['engine-session'] = { + n8nApiUrl: 'https://engine-test.example.com', + n8nApiKey: 'engine-key', + instanceId: 'engine-instance', + metadata: { env: 'production' } + }; + + // Export via engine + const exported = engine.exportSessionState(); + expect(exported).toHaveLength(1); + + // Clear sessions + delete serverAny.sessionMetadata['engine-session']; + delete serverAny.sessionContexts['engine-session']; + + // Restore via engine + const count = engine.restoreSessionState(exported); + expect(count).toBe(1); + + // Verify data + expect(serverAny.sessionMetadata['engine-session']).toBeDefined(); + expect(serverAny.sessionContexts['engine-session']).toMatchObject({ + n8nApiUrl: 'https://engine-test.example.com', + n8nApiKey: 'engine-key', + instanceId: 'engine-instance', + metadata: { env: 'production' } + }); + }); + }); + + describe('Integration with getSessionInfo()', () => { + it('should reflect restored sessions in session info', () => { + const sessions: SessionState[] = [ + { + sessionId: 'info-session-1', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://info1.example.com', + n8nApiKey: 'info-key-1', + instanceId: 'info-instance-1' + } + }, + { + sessionId: 'info-session-2', + metadata: { + createdAt: new Date().toISOString(), + lastAccess: new Date().toISOString() + }, + context: { + n8nApiUrl: 'https://info2.example.com', + n8nApiKey: 'info-key-2', + instanceId: 'info-instance-2' + } + } + ]; + + engine.restoreSessionState(sessions); + + const info = engine.getSessionInfo(); + + // Note: getSessionInfo() reflects metadata, not transports + // Restored sessions won't have transports until first request + expect(info).toBeDefined(); + }); + }); +});