mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-23 10:53:07 +00:00
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:
57
CHANGELOG.md
57
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
35
CLAUDE.md
35
CLAUDE.md
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
92
src/types/session-state.ts
Normal file
92
src/types/session-state.ts
Normal 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>;
|
||||||
|
};
|
||||||
|
}
|
||||||
542
tests/unit/http-server/session-persistence.test.ts
Normal file
542
tests/unit/http-server/session-persistence.test.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
255
tests/unit/mcp-engine/session-persistence.test.ts
Normal file
255
tests/unit/mcp-engine/session-persistence.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user