feat: Add session persistence API for zero-downtime deployments (v2.24.1)

Implements export/restore functionality for MCP sessions to support container
restarts without losing user sessions. This enables zero-downtime deployments
for multi-tenant platforms and Kubernetes/Docker environments.

New Features:
- exportSessionState() - Export active sessions to JSON
- restoreSessionState() - Restore sessions from exported data
- SessionState type - Serializable session structure
- Comprehensive test suite (22 tests, 100% passing)

Implementation Details:
- Only exports sessions with valid n8nApiUrl and n8nApiKey
- Automatically filters expired sessions (respects sessionTimeout)
- Validates context structure using existing validation
- Handles null/invalid sessions gracefully with warnings
- Enforces MAX_SESSIONS limit during restore (100 sessions)
- Dormant sessions recreate transport/server on first request

Files Modified:
- src/http-server-single-session.ts: Core export/restore logic
- src/mcp-engine.ts: Public API wrapper methods
- src/types/session-state.ts: Type definitions
- tests/: Comprehensive unit tests

Security Note:
Session data contains plaintext n8n API keys. Downstream applications
MUST encrypt session data before persisting to disk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
This commit is contained in:
czlonkowski
2025-11-24 17:38:46 +01:00
parent 9050967cd6
commit f5cf1e2934
10 changed files with 1222 additions and 7 deletions

View File

@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [2.24.0] - 2025-01-24
### ✨ Features ### ✨ Features

View File

@@ -32,7 +32,9 @@ src/
│ ├── expression-validator.ts # n8n expression syntax validation (NEW in v2.5.0) │ ├── expression-validator.ts # n8n expression syntax validation (NEW in v2.5.0)
│ └── workflow-validator.ts # Complete workflow validation (NEW in v2.5.0) │ └── workflow-validator.ts # Complete workflow validation (NEW in v2.5.0)
├── types/ ├── 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/ ├── constants/
│ └── type-structures.ts # 22 complete type structures (NEW in v2.22.21) │ └── type-structures.ts # 22 complete type structures (NEW in v2.22.21)
├── templates/ ├── templates/
@@ -64,7 +66,9 @@ src/
│ ├── console-manager.ts # Console output isolation (NEW in v2.3.1) │ ├── console-manager.ts # Console output isolation (NEW in v2.3.1)
│ └── logger.ts # Logging utility with HTTP awareness │ └── logger.ts # Logging utility with HTTP awareness
├── http-server-single-session.ts # Single-session HTTP server (NEW in v2.3.1) ├── 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) ├── 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 └── index.ts # Library exports
``` ```
@@ -191,6 +195,35 @@ The MCP server exposes tools in several categories:
### Development Best Practices ### Development Best Practices
- Run typecheck and lint after every code change - 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 # important-instruction-reminders
Do what has been asked; nothing more, nothing less. Do what has been asked; nothing more, nothing less.
NEVER create files unless they're absolutely necessary for achieving your goal. NEVER create files unless they're absolutely necessary for achieving your goal.

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.24.0", "version": "2.24.1",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -25,6 +25,7 @@ import {
STANDARD_PROTOCOL_VERSION STANDARD_PROTOCOL_VERSION
} from './utils/protocol-version'; } from './utils/protocol-version';
import { InstanceContext, validateInstanceContext } from './types/instance-context'; import { InstanceContext, validateInstanceContext } from './types/instance-context';
import { SessionState } from './types/session-state';
dotenv.config(); dotenv.config();
@@ -687,7 +688,20 @@ export class SingleSessionHTTPServer {
if (!this.session) return true; if (!this.session) return true;
return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout; 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 * Start the HTTP server
*/ */
@@ -1406,6 +1420,174 @@ 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[] = [];
// Iterate over all sessions with metadata (source of truth for active sessions)
for (const sessionId of Object.keys(this.sessionMetadata)) {
// 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;
}
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`);
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;
const currentSessionCount = Object.keys(this.transports).length;
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
if (currentSessionCount + restoredCount >= MAX_SESSIONS) {
logger.warn(
`Reached MAX_SESSIONS limit (${MAX_SESSIONS}), skipping remaining sessions`
);
break;
}
// Skip if session already exists (duplicate sessionId)
if (this.sessionMetadata[sessionState.sessionId]) {
logger.debug(`Skipping session ${sessionState.sessionId} - already exists`);
continue;
}
// Validate session isn't expired
const lastAccess = new Date(sessionState.metadata.lastAccess);
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 required context fields
if (
!sessionState.context ||
!sessionState.context.n8nApiUrl ||
!sessionState.context.n8nApiKey
) {
logger.warn(
`Skipping session ${sessionState.sessionId} - missing required context fields`
);
continue;
}
// Validate context structure using existing validation
try {
validateInstanceContext(sessionState.context);
} catch (error) {
logger.warn(
`Skipping session ${sessionState.sessionId} - invalid context:`,
error
);
continue;
}
// Restore session metadata
this.sessionMetadata[sessionState.sessionId] = {
createdAt: new Date(sessionState.metadata.createdAt),
lastAccess: new Date(sessionState.metadata.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}`);
restoredCount++;
} catch (error) {
logger.error(`Failed to restore session ${sessionState.sessionId}:`, 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 // Start if called directly

View File

@@ -18,6 +18,9 @@ export {
validateInstanceContext, validateInstanceContext,
isInstanceContext isInstanceContext
} from './types/instance-context'; } from './types/instance-context';
export type {
SessionState
} from './types/session-state';
// Re-export MCP SDK types for convenience // Re-export MCP SDK types for convenience
export type { export type {

View File

@@ -9,6 +9,7 @@ import { Request, Response } from 'express';
import { SingleSessionHTTPServer } from './http-server-single-session'; import { SingleSessionHTTPServer } from './http-server-single-session';
import { logger } from './utils/logger'; import { logger } from './utils/logger';
import { InstanceContext } from './types/instance-context'; import { InstanceContext } from './types/instance-context';
import { SessionState } from './types/session-state';
export interface EngineHealth { export interface EngineHealth {
status: 'healthy' | 'unhealthy'; status: 'healthy' | 'unhealthy';
@@ -97,7 +98,7 @@ export class N8NMCPEngine {
total: Math.round(memoryUsage.heapTotal / 1024 / 1024), total: Math.round(memoryUsage.heapTotal / 1024 / 1024),
unit: 'MB' unit: 'MB'
}, },
version: '2.3.2' version: '2.24.1'
}; };
} catch (error) { } catch (error) {
logger.error('Health check failed:', error); logger.error('Health check failed:', error);
@@ -106,7 +107,7 @@ export class N8NMCPEngine {
uptime: 0, uptime: 0,
sessionActive: false, sessionActive: false,
memoryUsage: { used: 0, total: 0, unit: 'MB' }, 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 } { getSessionInfo(): { active: boolean; sessionId?: string; age?: number } {
return this.server.getSessionInfo(); 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 * Graceful shutdown for service lifecycle
* *
* @example * @example
* process.on('SIGTERM', async () => { * process.on('SIGTERM', async () => {
* await engine.shutdown(); * await engine.shutdown();

View File

@@ -1,6 +1,8 @@
// Export n8n node type definitions and utilities // Export n8n node type definitions and utilities
export * from './node-types'; export * from './node-types';
export * from './type-structures'; export * from './type-structures';
export * from './instance-context';
export * from './session-state';
export interface MCPServerConfig { export interface MCPServerConfig {
port: number; port: number;

View File

@@ -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<string, any>;
};
}

View File

@@ -0,0 +1,542 @@
/**
* 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;
for (let i = 0; i < 99; i++) {
serverAny.transports[`existing-${i}`] = {}; // Mock transport
}
// 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' }
});
});
});
});

View File

@@ -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();
});
});
});