diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee7b92..c4c3dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,432 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.19.5] - 2025-10-13 - -### ๐Ÿ› Critical Bug Fixes - -**Session Restoration Handshake (P0 - CRITICAL)** - -Fixes critical bug in session restoration where synthetic MCP initialization had no HTTP connection to respond through, causing timeouts. Implements warm start pattern that handles the current request immediately. - -#### Fixed - -- **Synthetic MCP Initialization Failed Due to Missing HTTP Context** - - **Issue**: v2.19.4's `initializeMCPServerForSession()` attempted to synthetically initialize restored MCP servers, but had no active HTTP req/res pair to send responses through, causing all restoration attempts to timeout - - **Impact**: Session restoration completely broken - zero-downtime deployments non-functional - - **Severity**: CRITICAL - v2.19.4 introduced a regression that broke session restoration - - **Root Cause**: - - `StreamableHTTPServerTransport` requires a live HTTP req/res pair to send responses - - Synthetic initialization called `server.request()` but had no transport attached to current request - - Transport's `_initialized` flag stayed false because no actual GET/POST went through it - - Retrying with backoff didn't help - the transport had nothing to talk to - - **Fix Applied**: - - **Deleted broken synthetic initialization method** (`initializeMCPServerForSession()`) - - **Implemented warm start pattern**: - 1. Restore session by calling existing `createSession()` with restored context - 2. Immediately handle current request through new transport: `transport.handleRequest(req, res, req.body)` - 3. Client receives standard MCP error `-32000` (Server not initialized) - 4. Client auto-retries with initialize on same connection (standard MCP behavior) - 5. Session fully restored and client continues normally - - **Added idempotency guards** to prevent concurrent restoration from creating duplicate sessions - - **Added cleanup on failure** to remove sessions when restoration fails - - **Added early return** after handling request to prevent double processing - - **Location**: `src/http-server-single-session.ts:1118-1247` (simplified restoration flow) - - **Tests Added**: `tests/integration/session-restoration-warmstart.test.ts` (11 comprehensive tests) - - **Documentation**: `docs/MULTI_APP_INTEGRATION.md` (warm start behavior explained) - -#### Technical Details - -**Warm Start Pattern Flow:** -1. Client sends request with unknown session ID (after restart) -2. Server detects unknown session, calls `onSessionNotFound` hook -3. Hook loads session context from database -4. Server creates session using existing `createSession()` flow -5. Server immediately handles current request through new transport -6. Client receives `-32000` error, auto-retries with initialize -7. Session fully restored, client continues normally - -**Benefits:** -- **Zero client changes**: Standard MCP clients auto-retry on -32000 -- **Single HTTP round-trip**: No extra network requests needed -- **Concurrent-safe**: Idempotency guards prevent race conditions -- **Automatic cleanup**: Failed restorations clean up resources -- **Standard MCP**: Uses official error code, not custom solutions - -**Code Changes:** -```typescript -// Before (v2.19.4 - BROKEN): -await server.connect(transport); -await this.initializeMCPServerForSession(sessionId, server, context); // NO req/res to respond! - -// After (v2.19.5 - WORKING): -this.createSession(restoredContext, sessionId, false); -transport = this.transports[sessionId]; -await transport.handleRequest(req, res, req.body); // Handle current request immediately -return; // Early return prevents double processing -``` - -#### Migration Notes - -This is a **patch release** with no breaking changes: -- No API changes to public interfaces -- Existing session restoration hooks work unchanged -- Internal implementation simplified (80 fewer lines of code) -- Session restoration now works correctly with standard MCP protocol - -#### Files Changed - -- `src/http-server-single-session.ts`: Deleted synthetic init, implemented warm start (lines 1118-1247) -- `tests/integration/session-restoration-warmstart.test.ts`: New integration tests (11 tests) -- `docs/MULTI_APP_INTEGRATION.md`: Documentation for warm start pattern -- `package.json`, `package.runtime.json`: Version bump to 2.19.5 - -## [2.19.4] - 2025-10-13 - -### ๐Ÿ› Critical Bug Fixes - -**MCP Server Initialization for Restored Sessions (P0 - CRITICAL)** - -Completes the session restoration feature by initializing MCP server instances for restored sessions, enabling tool calls to work after server restart. - -#### Fixed - -- **MCP Server Not Initialized During Session Restoration** - - **Issue**: Session restoration successfully restored InstanceContext (v2.19.0) and transport layer (v2.19.3), but failed to initialize the MCP Server instance, causing all tool calls on restored sessions to fail with "Server not initialized" error - - **Impact**: Zero-downtime deployments still broken - users cannot use tools after container restart without manually restarting their MCP client - - **Severity**: CRITICAL - session persistence incomplete without MCP server initialization - - **Root Cause**: - - MCP protocol requires an `initialize` handshake before accepting tool calls - - When restoring a session, we create a NEW MCP Server instance (uninitialized state) - - Client thinks it already initialized (it did, with the old instance before restart) - - Client sends tool call, new server rejects it: "Server not initialized" - - The three layers of a session: (1) Data (InstanceContext) โœ…, (2) Transport (HTTP) โœ… v2.19.3, (3) Protocol (MCP Server) โŒ not initialized - - **Fix Applied**: - - Created `initializeMCPServerForSession()` method that sends synthetic initialize request to new MCP server instance - - Brings server into initialized state without requiring client to re-initialize - - Called after `server.connect(transport)` during session restoration flow - - Includes 5-second timeout and comprehensive error handling - - **Location**: `src/http-server-single-session.ts:521-605` (new method), `src/http-server-single-session.ts:1321-1327` (application) - - **Tests**: Compilation verified, ready for integration testing - - **Verification**: Build successful, no TypeScript errors - -#### Technical Details - -**The Three Layers of Session State:** -1. **Data Layer** (InstanceContext): Session configuration and state โœ… v2.19.0 -2. **Transport Layer** (HTTP Connection): Request/response binding โœ… v2.19.3 -3. **Protocol Layer** (MCP Server Instance): Initialize handshake โœ… v2.19.4 - -**Implementation:** -```typescript -// After connecting transport, initialize the MCP server -await server.connect(transport); -await this.initializeMCPServerForSession(sessionId, server, restoredContext); -``` - -The synthetic initialize request: -- Uses standard MCP protocol version -- Includes client info: `n8n-mcp-restored-session` -- Calls server's initialize handler directly -- Waits for initialization to complete (5 second timeout) -- Brings server into initialized state - -#### Dependencies - -- Requires: v2.19.3 (transport layer fix) -- Completes: Session persistence feature (v2.19.0-v2.19.4) -- Enables: True zero-downtime deployments for HTTP-based deployments - -## [2.19.3] - 2025-10-13 - -### ๐Ÿ› Critical Bug Fixes - -**Session Restoration Transport Layer (P0 - CRITICAL)** - -Fixes critical bug where session restoration successfully restored InstanceContext but failed to reconnect the transport layer, causing all requests on restored sessions to hang indefinitely. - -#### Fixed - -- **Transport Layer Not Reconnected During Session Restoration** - - **Issue**: Session restoration successfully restored InstanceContext (session state) but failed to connect transport layer (HTTP req/res binding), causing requests to hang indefinitely - - **Impact**: Zero-downtime deployments broken - users cannot continue work after container restart without restarting their MCP client (Claude Desktop, Cursor, Windsurf) - - **Severity**: CRITICAL - session persistence completely non-functional for production use - - **Root Cause**: - - The `handleRequest()` method's session restoration flow (lines 1119-1197) called `createSession()` which creates a NEW transport separate from the current HTTP request - - This separate transport is not linked to the current req/res pair, so responses cannot be sent back through the active HTTP connection - - The initialize flow (lines 946-1055) correctly creates transport inline for the current request, but restoration flow did not follow this pattern - - **Fix Applied**: - - Replace `createSession()` call with inline transport creation that mirrors the initialize flow - - Create `StreamableHTTPServerTransport` directly for the current HTTP req/res context - - Ensure transport is connected to server BEFORE handling request - - This makes restored sessions work identically to fresh sessions - - **Location**: `src/http-server-single-session.ts:1163-1244` - - **Tests Added**: - - Integration tests: `tests/integration/session-persistence.test.ts` (13 tests all passing) - - **Verification**: All session persistence integration tests passing - -#### Technical Details - -**Before Fix (Broken):** -```typescript -// Session restoration (WRONG - creates separate transport) -await this.createSession(restoredContext, sessionId, true); -transport = this.transports[sessionId]; // Transport NOT linked to current req/res! -``` - -**After Fix (Working):** -```typescript -// Session restoration (CORRECT - inline transport for current request) -const server = new N8NDocumentationMCPServer(restoredContext); -transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => sessionId, - onsessioninitialized: (id) => { - this.transports[id] = transport; // Store for future requests - this.servers[id] = server; - // ... metadata storage - } -}); -await server.connect(transport); // Connect BEFORE handling request -``` - -**Why This Matters:** -- The `StreamableHTTPServerTransport` class from MCP SDK links a specific HTTP req/res pair to the MCP server -- Creating transport in `createSession()` binds it to the wrong req/res (or no req/res at all) -- Responses sent through the wrong transport never reach the client -- The initialize flow got this right, but restoration flow did not - -**Impact on Zero-Downtime Deployments:** -- โœ… **After fix**: Container restart โ†’ Client reconnects with old session ID โ†’ Session restored โ†’ Requests work normally -- โŒ **Before fix**: Container restart โ†’ Client reconnects with old session ID โ†’ Session restored โ†’ Requests hang forever - -#### Migration Notes - -This is a **patch release** with no breaking changes: -- No API changes -- No configuration changes required -- Existing code continues to work -- Session restoration now actually works as designed - -#### Files Changed - -- `src/http-server-single-session.ts`: Fixed session restoration to create transport inline (lines 1163-1244) -- `package.json`, `package.runtime.json`, `src/mcp-engine.ts`: Version bump to 2.19.3 -- `tests/integration/session-persistence.test.ts`: Existing tests verify restoration works correctly - -## [2.19.2] - 2025-10-13 - -### ๐Ÿ› Critical Bug Fixes - -**Session Cleanup Stack Overflow (P0 - CRITICAL)** - -Fixes critical stack overflow bug that caused service to become unresponsive after container restart. - -#### Fixed - -- **Stack Overflow During Session Cleanup** - - **Issue**: Missing `await` in cleanup loop caused concurrent async operations and recursive cleanup cascade - - **Impact**: Stack overflow errors during container restart, all subsequent tool calls hang indefinitely - - **Severity**: CRITICAL - makes service unusable after restart for all users with session persistence - - **Root Causes**: - 1. `cleanupExpiredSessions()` line 206 called `removeSession()` without `await`, causing overlapping cleanup attempts - 2. Transport event handlers (`onclose`, `onerror`) triggered recursive cleanup during shutdown - 3. No recursion guard to prevent concurrent cleanup of same session - - **Fixes Applied**: - 1. Added `cleanupInProgress: Set` recursion guard to prevent concurrent cleanup - 2. Added `isShuttingDown` flag to prevent recursive event handlers during shutdown - 3. Implemented `safeCloseTransport()` helper with timeout protection (3 seconds) - 4. Updated `removeSession()` to check recursion guard and use safe transport closing - 5. Fixed `cleanupExpiredSessions()` to properly `await` with error isolation - 6. Updated all transport event handlers to check shutdown flag before cleanup - 7. Enhanced `shutdown()` method to set flag and use proper sequential cleanup - - **Location**: `src/http-server-single-session.ts` - - **Verification**: All 77 session lifecycle tests passing - -#### Technical Details - -**Recursion Chain (Before Fix):** -``` -cleanupExpiredSessions() - โ””โ”€> removeSession(session, 'expired') [NOT AWAITED] - โ””โ”€> transport.close() - โ””โ”€> transport.onclose handler - โ””โ”€> removeSession(session, 'transport_closed') - โ””โ”€> transport.close() [AGAIN!] - โ””โ”€> Stack overflow! -``` - -**Protection Added:** -- **Recursion Guard**: Prevents same session from being cleaned up concurrently -- **Shutdown Flag**: Disables event handlers during shutdown to break recursion chain -- **Safe Transport Close**: Removes event handlers before closing, uses timeout protection -- **Error Isolation**: Each session cleanup failure doesn't affect others -- **Sequential Cleanup**: Properly awaits each operation to prevent race conditions - -#### Impact - -- **Reliability**: Service survives container restarts without stack overflow -- **Stability**: No more hanging requests after restart -- **Resilience**: Individual session cleanup failures don't cascade -- **Backward Compatible**: No breaking changes, all existing tests pass - -## [2.19.1] - 2025-10-12 - -### ๐Ÿ› Bug Fixes - -**Session Lifecycle Event Emission** - -Fixes issue where `onSessionCreated` event was not being emitted during standard session initialization flow (when sessions are created directly without restoration). - -#### Fixed - -- **onSessionCreated Event Missing in Standard Flow** - - **Issue**: `onSessionCreated` event was only emitted during restoration failure fallback, not during normal session creation - - **Impact**: Applications relying on `onSessionCreated` for logging, monitoring, or persistence didn't receive events for directly created sessions - - **Root Cause**: Event emission was only present in restoration error handler, not in standard `initialize()` flow - - **Fix**: Added `onSessionCreated` event emission in `http-server-single-session.ts:436` during standard initialization - - **Location**: `src/http-server-single-session.ts` (initialize method) - - **Verification**: All session lifecycle tests passing (14 tests) - -#### Impact - -- **Event Consistency**: `onSessionCreated` now fires reliably for all new sessions (whether created directly or after restoration failure) -- **Monitoring**: Complete session lifecycle visibility for logging and analytics systems -- **Backward Compatible**: No breaking changes, only adds missing event emission - -## [2.19.0] - 2025-10-12 - -### โœจ New Features - -**Session Lifecycle Events (Phase 3 - REQ-4)** - -Adds optional callback-based event system for monitoring session lifecycle, enabling integration with logging, monitoring, and analytics systems. - -#### Added - -- **Session Lifecycle Event Handlers** - - `onSessionCreated`: Called when new session is created (not restored) - - `onSessionRestored`: Called when session is restored from external storage - - `onSessionAccessed`: Called on every request using existing session - - `onSessionExpired`: Called when session expires due to inactivity - - `onSessionDeleted`: Called when session is manually deleted - - **Implementation**: `src/types/session-restoration.ts` (SessionLifecycleEvents interface) - - **Integration**: `src/http-server-single-session.ts` (event emission at 5 lifecycle points) - - **API**: `src/mcp-engine.ts` (sessionEvents option) - -- **Event Characteristics** - - **Fire-and-forget**: Non-blocking, errors logged but don't affect operations - - **Async Support**: Handlers can be sync or async - - **Graceful Degradation**: Handler failures don't break session operations - - **Metadata Support**: Events receive session ID and instance context - -#### Use Cases - -- **Logging & Monitoring**: Track session lifecycle for debugging and analytics -- **Database Persistence**: Auto-save sessions on creation/restoration -- **Metrics**: Track session activity and expiration patterns -- **Cleanup**: Cascade delete related data when sessions expire -- **Throttling**: Update lastAccess timestamps (with throttling for performance) - -#### Example Usage - -```typescript -import { N8NMCPEngine } from 'n8n-mcp'; -import throttle from 'lodash.throttle'; - -const engine = new N8NMCPEngine({ - sessionEvents: { - onSessionCreated: async (sessionId, context) => { - await db.saveSession(sessionId, context); - analytics.track('session_created', { sessionId }); - }, - onSessionRestored: async (sessionId, context) => { - analytics.track('session_restored', { sessionId }); - }, - // Throttle high-frequency event to prevent DB overload - onSessionAccessed: throttle(async (sessionId) => { - await db.updateLastAccess(sessionId); - }, 60000), // Max once per minute - onSessionExpired: async (sessionId) => { - await db.deleteSession(sessionId); - await cleanup.removeRelatedData(sessionId); - }, - onSessionDeleted: async (sessionId) => { - await db.deleteSession(sessionId); - } - } -}); -``` - ---- - -**Session Restoration Retry Policy (Phase 4 - REQ-7)** - -Adds configurable retry logic for transient failures during session restoration, improving reliability for database-backed persistence. - -#### Added - -- **Retry Configuration Options** - - `sessionRestorationRetries`: Number of retry attempts (default: 0, opt-in) - - `sessionRestorationRetryDelay`: Delay between attempts in milliseconds (default: 100ms) - - **Implementation**: `src/http-server-single-session.ts` (restoreSessionWithRetry method) - - **API**: `src/mcp-engine.ts` (retry options) - -- **Retry Behavior** - - **Overall Timeout**: Applies to ALL attempts combined, not per attempt - - **No Retry for Timeouts**: Timeout errors are never retried (already took too long) - - **Exponential Backoff**: Optional via custom delay configuration - - **Error Logging**: Logs each retry attempt with context - -#### Use Cases - -- **Database Retries**: Handle transient connection failures -- **Network Resilience**: Retry on temporary network errors -- **Rate Limit Handling**: Backoff and retry when hitting rate limits -- **High Availability**: Improve reliability of external storage - -#### Example Usage - -```typescript -const engine = new N8NMCPEngine({ - onSessionNotFound: async (sessionId) => { - // May fail transiently due to database load - return await database.loadSession(sessionId); - }, - sessionRestorationRetries: 3, // Retry up to 3 times - sessionRestorationRetryDelay: 100, // 100ms between retries - sessionRestorationTimeout: 5000 // 5s total for all attempts -}); -``` - -#### Error Handling - -- **Retryable Errors**: Database connection failures, network errors, rate limits -- **Non-Retryable**: Timeout errors (already exceeded time limit) -- **Logging**: Each retry logged with attempt number and error details - -#### Testing - -- **Unit Tests**: 34 tests passing (14 lifecycle events + 20 retry policy) - - `tests/unit/session-lifecycle-events.test.ts` (14 tests) - - `tests/unit/session-restoration-retry.test.ts` (20 tests) -- **Integration Tests**: 14 tests covering combined behavior - - `tests/integration/session-lifecycle-retry.test.ts` -- **Coverage**: Event emission, retry logic, timeout handling, backward compatibility - -#### Documentation - -- **Types**: Full JSDoc documentation in type definitions -- **Examples**: Practical examples in CHANGELOG and type comments -- **Migration**: Backward compatible - no breaking changes - -#### Impact - -- **Reliability**: Improved session restoration success rate -- **Observability**: Complete visibility into session lifecycle -- **Integration**: Easy integration with existing monitoring systems -- **Performance**: Non-blocking event handlers prevent slowdowns -- **Flexibility**: Opt-in retry policy with sensible defaults - ## [2.18.8] - 2025-10-11 ### ๐Ÿ› Bug Fixes @@ -2934,139 +2508,6 @@ get_node_essentials({ - Added telemetry configuration instructions to README - Updated CLAUDE.md with telemetry system architecture -## [2.19.0] - 2025-10-12 - -### Added - -**Session Persistence for Multi-Tenant Deployments (Phase 1 + Phase 2)** - -This release introduces production-ready session persistence enabling stateless multi-tenant deployments with session restoration and complete session lifecycle management. - -#### Phase 1: Session Restoration Hook (REQ-1 to REQ-4) - -- **Automatic Session Restoration** - - New `onSessionNotFound` hook for session restoration from external storage - - Async database lookup when client sends unknown session ID - - Configurable restoration timeout (default 5 seconds) - - Seamless integration with existing multi-tenant API - -- **Core Capabilities** - - Restore sessions from Redis, PostgreSQL, or any external storage - - Support for session metadata and custom context - - Timeout protection prevents hanging requests - - Backward compatible - optional feature, zero breaking changes - -- **Integration Points** - - Hook called before session validation in handleRequest flow - - Thread-safe session restoration with proper locking - - Error handling with detailed logging - - Production-tested with comprehensive test coverage - -#### Phase 2: Session Management API (REQ-5) - -- **Session Lifecycle Management** - - `getActiveSessions()`: List all active session IDs - - `getSessionState(sessionId)`: Get complete session state - - `getAllSessionStates()`: Bulk export for periodic backups - - `restoreSession(sessionId, context)`: Manual session restoration - - `deleteSession(sessionId)`: Explicit session cleanup - -- **Session State Information** - - Session ID, instance context, metadata - - Creation time, last access, expiration time - - Serializable for database storage - -- **Workflow Support** - - Periodic backup: Export all sessions every N minutes - - Bulk restore: Load sessions on server restart - - Manual cleanup: Remove sessions from external trigger - -#### Security Improvements - -- **Session ID Validation** - - Length validation (20-100 characters) - - Character whitelist (alphanumeric, hyphens, underscores) - - SQL injection prevention - - Path traversal prevention - - Early validation before restoration hook - -- **Orphan Detection** - - Comprehensive cleanup of orphaned session components - - Detects and removes orphaned transports - - Detects and removes orphaned servers - - Prevents memory leaks from incomplete cleanup - - Warning logs for orphaned resources - -- **Rate Limiting Documentation** - - Security notes in JSDoc for `onSessionNotFound` - - Recommendations for preventing database lookup abuse - - Guidance on implementing express-rate-limit - -#### Technical Implementation - -- **Files Changed**: - - `src/types/session-restoration.ts`: New types for session restoration - - `src/http-server-single-session.ts`: Hook integration and session management API - - `src/mcp-engine.ts`: Public API methods for session lifecycle - - `tests/unit/session-management-api.test.ts`: 21 unit tests - - `tests/integration/session-persistence.test.ts`: 13 integration tests - -- **Testing**: - - โœ… 34 total tests (21 unit + 13 integration) - - โœ… All edge cases covered (timeouts, errors, validation) - - โœ… Thread safety verified - - โœ… Memory leak prevention tested - - โœ… Backward compatibility confirmed - -#### Migration Guide - -**For Existing Users (No Changes Required)** -```typescript -// Your existing code continues to work unchanged -const engine = new N8NMCPEngine(); -await engine.processRequest(req, res, instanceContext); -``` - -**For New Session Persistence Users** -```typescript -// 1. Implement restoration hook -const engine = new N8NMCPEngine({ - onSessionNotFound: async (sessionId) => { - // Load from your database - const session = await db.loadSession(sessionId); - return session ? session.instanceContext : null; - }, - sessionRestorationTimeout: 5000 -}); - -// 2. Periodic backup (optional) -setInterval(async () => { - const states = engine.getAllSessionStates(); - for (const state of states) { - await db.upsertSession(state); - } -}, 300000); // Every 5 minutes - -// 3. Restore on server start (optional) -const savedSessions = await db.loadAllSessions(); -for (const session of savedSessions) { - engine.restoreSession(session.sessionId, session.instanceContext); -} -``` - -#### Benefits - -- **Stateless Deployment**: No session state in memory, safe for container restarts -- **Multi-Tenant Support**: Each tenant's sessions persist independently -- **High Availability**: Sessions survive server crashes and deployments -- **Scalability**: Share session state across multiple server instances -- **Cost Efficient**: Use Redis, PostgreSQL, or any database for persistence - -### Documentation -- Added comprehensive session persistence documentation -- Added migration guide and examples -- Updated API documentation with session management methods - ## Previous Versions For changes in previous versions, please refer to the git history and release notes. \ No newline at end of file diff --git a/IMPLEMENTATION_GUIDE.md b/IMPLEMENTATION_GUIDE.md deleted file mode 100644 index 1a36cc5..0000000 --- a/IMPLEMENTATION_GUIDE.md +++ /dev/null @@ -1,3491 +0,0 @@ -# n8n-mcp MVP: Developer Implementation Guide - -**Version:** 1.0 -**Target:** 2.5 week MVP launch -**Audience:** Backend, Frontend, DevOps engineers -**Date:** 2025-10-11 - ---- - -## ๐Ÿ“‹ Table of Contents - -1. [Prerequisites](#prerequisites) -2. [Phase 0: Environment Setup (Day 0)](#phase-0-environment-setup) -3. [Phase 1: Backend Implementation (Days 1-4)](#phase-1-backend-implementation) -4. [Phase 2: Frontend Implementation (Days 5-9)](#phase-2-frontend-implementation) -5. [Phase 3: Testing & Launch (Days 10-12)](#phase-3-testing--launch) -6. [Troubleshooting](#troubleshooting) -7. [Rollback Procedures](#rollback-procedures) - ---- - -## Prerequisites - -### Development Environment - -**Required Tools:** -- [ ] Node.js 20+ LTS -- [ ] npm 10+ -- [ ] Docker & Docker Compose -- [ ] Git -- [ ] Code editor (VS Code recommended) -- [ ] curl / Postman for API testing - -**Optional but Recommended:** -- [ ] Docker Desktop (for local testing) -- [ ] GitHub CLI (`gh`) -- [ ] Supabase CLI (`npx supabase`) - -### Access & Accounts - -**Must Have:** -- [ ] GitHub account with access to `czlonkowski/n8n-mcp` repo -- [ ] Supabase account (free tier) -- [ ] Hetzner Cloud account -- [ ] Domain access to `n8n-mcp.com` DNS - -**Nice to Have:** -- [ ] Vercel account (for frontend hosting) -- [ ] Testing n8n instance with API key - -### Knowledge Prerequisites - -**Backend Developer:** -- TypeScript/Node.js -- REST APIs & HTTP servers -- PostgreSQL & SQL -- Docker basics -- Encryption (AES-256-GCM) - -**Frontend Developer:** -- React 19 & Next.js 15 -- TypeScript -- Supabase client SDK -- Server Components & Server Actions - -**DevOps:** -- Docker Compose -- Caddy/nginx basics -- DNS configuration -- SSL/TLS certificates - ---- - -## Phase 0: Environment Setup - -**Goal:** Get development environment ready -**Time:** 2-4 hours -**Assignee:** All team members - -### 0.1 Clone Repository - -```bash -# Clone n8n-mcp backend -git clone https://github.com/czlonkowski/n8n-mcp.git -cd n8n-mcp - -# Create feature branch -git checkout -b feature/multi-tenant-mvp - -# Install dependencies -npm install - -# Build to verify setup -npm run build -``` - -**Verification:** -```bash -npm run typecheck # Should pass -npm test # Existing tests should pass -``` - -### 0.2 Create Supabase Project - -**Steps:** -1. Go to https://supabase.com/dashboard -2. Click "New Project" -3. Fill in: - - Name: `n8n-mcp-production` - - Database Password: Generate strong password (save securely!) - - Region: Europe (Frankfurt) - closest to Hetzner - - Plan: Free tier -4. Wait for provisioning (~2 minutes) - -**Get Credentials:** -```bash -# From Project Settings > API -SUPABASE_URL=https://xxxxx.supabase.co -SUPABASE_ANON_KEY=eyJxxxxx # For frontend -SUPABASE_SERVICE_KEY=eyJxxxxx # For backend (bypasses RLS) -``` - -**Create `.env.local` file:** -```bash -# Backend .env.local -SUPABASE_URL=https://xxxxx.supabase.co -SUPABASE_SERVICE_KEY=eyJxxxxx -SESSION_SECRET=generate-random-32-char-string -NODE_ENV=development -MCP_MODE=http -PORT=3000 -ENABLE_MULTI_TENANT=true -``` - -### 0.3 Provision Hetzner Server (Optional for Local Dev) - -**For Production Deployment:** -1. Go to https://console.hetzner.cloud -2. Create new project: `n8n-mcp-production` -3. Add server: - - Type: CPX31 (4 vCPU, 8GB RAM) - - Location: Falkenstein, Germany - - Image: Ubuntu 22.04 LTS - - Add SSH key -4. Note server IP: `XXX.XXX.XXX.XXX` - -**Initial Server Setup:** -```bash -ssh root@XXX.XXX.XXX.XXX - -# Update system -apt update && apt upgrade -y - -# Install Docker -curl -fsSL https://get.docker.com -o get-docker.sh -sh get-docker.sh - -# Install Docker Compose -apt install docker-compose-plugin -y - -# Verify -docker --version -docker compose version -``` - -### 0.4 Configure DNS - -**Add DNS Records:** -``` -Type Name Value TTL -A api.n8n-mcp.com XXX.XXX.XXX.XXX 300 -A www.n8n-mcp.com (Vercel IP) 300 -``` - -**Verification:** -```bash -dig api.n8n-mcp.com +short # Should return server IP -``` - ---- - -## Phase 1: Backend Implementation - -**Goal:** Multi-tenant n8n-mcp service with API key auth -**Time:** 3-4 days -**Assignee:** Backend developer - -### Day 1: Database Schema & Supabase Setup - -#### 1.1 Deploy Database Schema - -**File:** `supabase/schema.sql` (create this file) - -```sql --- Enable UUID extension -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Users table (extends auth.users) -CREATE TABLE public.users ( - id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, - email TEXT NOT NULL UNIQUE, - full_name TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- API Keys table (n8n-mcp keys, not n8n instance keys!) -CREATE TABLE public.api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - key_hash TEXT NOT NULL UNIQUE, - key_prefix TEXT NOT NULL, -- e.g., "nmcp_abc123..." - name TEXT NOT NULL, -- User-friendly name - last_used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - is_active BOOLEAN DEFAULT TRUE -); - --- n8n Instance Configuration (user's actual n8n credentials) -CREATE TABLE public.n8n_instances ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - instance_url TEXT NOT NULL, - api_key_encrypted TEXT NOT NULL, -- Encrypted n8n API key - is_active BOOLEAN DEFAULT TRUE, - last_validated_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - CONSTRAINT unique_user_instance UNIQUE(user_id, instance_url) -); - --- Usage tracking (basic for MVP) -CREATE TABLE public.usage_logs ( - id BIGSERIAL PRIMARY KEY, - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - api_key_id UUID REFERENCES public.api_keys(id) ON DELETE SET NULL, - tool_name TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('success', 'error', 'rate_limited')), - error_message TEXT, - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- Indexes for performance -CREATE INDEX idx_api_keys_user_id ON public.api_keys(user_id); -CREATE INDEX idx_api_keys_key_hash ON public.api_keys(key_hash); -CREATE INDEX idx_api_keys_active ON public.api_keys(is_active) WHERE is_active = true; -CREATE INDEX idx_n8n_instances_user_id ON public.n8n_instances(user_id); -CREATE INDEX idx_usage_logs_user_id ON public.usage_logs(user_id); -CREATE INDEX idx_usage_logs_created_at ON public.usage_logs(created_at DESC); - --- Enable Row Level Security -ALTER TABLE public.users ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.api_keys ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.n8n_instances ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.usage_logs ENABLE ROW LEVEL SECURITY; - --- RLS Policies - --- Users can view own data -CREATE POLICY "Users can view own data" ON public.users - FOR SELECT USING (auth.uid() = id); - -CREATE POLICY "Users can update own data" ON public.users - FOR UPDATE USING (auth.uid() = id); - --- Users can manage own API keys -CREATE POLICY "Users can view own API keys" ON public.api_keys - FOR SELECT USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert own API keys" ON public.api_keys - FOR INSERT WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Users can update own API keys" ON public.api_keys - FOR UPDATE USING (auth.uid() = user_id); - -CREATE POLICY "Users can delete own API keys" ON public.api_keys - FOR DELETE USING (auth.uid() = user_id); - --- Users can manage own n8n instances -CREATE POLICY "Users can view own n8n config" ON public.n8n_instances - FOR SELECT USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert own n8n config" ON public.n8n_instances - FOR INSERT WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Users can update own n8n config" ON public.n8n_instances - FOR UPDATE USING (auth.uid() = user_id); - -CREATE POLICY "Users can delete own n8n config" ON public.n8n_instances - FOR DELETE USING (auth.uid() = user_id); - --- Users can view own usage logs -CREATE POLICY "Users can view own usage" ON public.usage_logs - FOR SELECT USING (auth.uid() = user_id); - --- Service role can do everything (for backend API key validation) --- This is automatic with service_role key - --- Function to auto-create user record on signup -CREATE OR REPLACE FUNCTION public.handle_new_user() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.users (id, email, full_name) - VALUES (NEW.id, NEW.email, NEW.raw_user_meta_data->>'full_name'); - RETURN NEW; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Trigger to create user on auth signup -CREATE TRIGGER on_auth_user_created - AFTER INSERT ON auth.users - FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); -``` - -**Deploy Schema:** - -**Option A: Supabase Dashboard** -1. Go to SQL Editor in Supabase dashboard -2. Paste entire schema -3. Click "Run" - -**Option B: Supabase CLI** -```bash -npx supabase db push -``` - -**Verification:** -```sql --- Run in SQL Editor -SELECT table_name -FROM information_schema.tables -WHERE table_schema = 'public' -ORDER BY table_name; - --- Should see: users, api_keys, n8n_instances, usage_logs -``` - -#### 1.2 Configure Supabase Auth - -**Steps:** -1. Go to Authentication > Settings -2. Enable Email provider (already enabled) -3. Configure Email Templates: - - Confirmation: Customize subject/body - - Magic Link: Disable (not using for MVP) -4. Site URL: `https://www.n8n-mcp.com` -5. Redirect URLs: Add `https://www.n8n-mcp.com/auth/callback` - -**Verification:** -- Send test signup email from dashboard -- Check email arrives and link works - ---- - -### Day 2-3: Multi-Tenant Backend Implementation - -#### 2.1 Create Encryption Service - -**File:** `src/services/encryption.ts` - -```typescript -import crypto from 'crypto'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 16; -const SALT_LENGTH = 64; -const TAG_LENGTH = 16; -const KEY_LENGTH = 32; - -/** - * Derives an encryption key from master secret + user ID - * This ensures each user has a unique encryption key - */ -function deriveKey(userId: string): Buffer { - const masterKey = process.env.MASTER_ENCRYPTION_KEY; - if (!masterKey) { - throw new Error('MASTER_ENCRYPTION_KEY not set'); - } - - return crypto.pbkdf2Sync( - masterKey, - userId, - 100000, - KEY_LENGTH, - 'sha512' - ); -} - -/** - * Encrypts data using AES-256-GCM - * Format: salt + iv + tag + encrypted data - */ -export function encrypt(plaintext: string, userId: string): string { - const key = deriveKey(userId); - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - - let encrypted = cipher.update(plaintext, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const tag = cipher.getAuthTag(); - - // Combine: iv + tag + encrypted - const result = Buffer.concat([ - iv, - tag, - Buffer.from(encrypted, 'hex') - ]); - - return result.toString('base64'); -} - -/** - * Decrypts data encrypted with encrypt() - */ -export function decrypt(ciphertext: string, userId: string): string { - const key = deriveKey(userId); - const buffer = Buffer.from(ciphertext, 'base64'); - - // Extract components - const iv = buffer.subarray(0, IV_LENGTH); - const tag = buffer.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); - const encrypted = buffer.subarray(IV_LENGTH + TAG_LENGTH); - - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(tag); - - let decrypted = decipher.update(encrypted); - decrypted = Buffer.concat([decrypted, decipher.final()]); - - return decrypted.toString('utf8'); -} -``` - -**Test:** -```typescript -// Create test file: src/services/encryption.test.ts -import { encrypt, decrypt } from './encryption'; - -describe('Encryption Service', () => { - beforeAll(() => { - process.env.MASTER_ENCRYPTION_KEY = 'test-master-key-32-chars-long!'; - }); - - test('should encrypt and decrypt correctly', () => { - const userId = 'test-user-id'; - const plaintext = 'my-n8n-api-key-secret'; - - const encrypted = encrypt(plaintext, userId); - const decrypted = decrypt(encrypted, userId); - - expect(decrypted).toBe(plaintext); - expect(encrypted).not.toBe(plaintext); - }); - - test('should fail with wrong user ID', () => { - const userId1 = 'user-1'; - const userId2 = 'user-2'; - const plaintext = 'secret'; - - const encrypted = encrypt(plaintext, userId1); - - expect(() => decrypt(encrypted, userId2)).toThrow(); - }); -}); -``` - -Run test: -```bash -npm test -- src/services/encryption.test.ts -``` - -#### 2.2 Create Supabase Client Service - -**File:** `src/services/database.ts` - -```typescript -import { createClient } from '@supabase/supabase-js'; - -// Singleton pattern for Supabase client -let supabaseClient: ReturnType | null = null; - -export function getSupabaseClient() { - if (supabaseClient) return supabaseClient; - - const supabaseUrl = process.env.SUPABASE_URL; - const supabaseKey = process.env.SUPABASE_SERVICE_KEY; - - if (!supabaseUrl || !supabaseKey) { - throw new Error('SUPABASE_URL and SUPABASE_SERVICE_KEY must be set'); - } - - supabaseClient = createClient(supabaseUrl, supabaseKey, { - auth: { - persistSession: false, // Server-side, no sessions - autoRefreshToken: false - }, - db: { - schema: 'public' - } - }); - - return supabaseClient; -} - -// Type definitions for database -export interface User { - id: string; - email: string; - full_name: string | null; - created_at: string; - updated_at: string; -} - -export interface ApiKey { - id: string; - user_id: string; - key_hash: string; - key_prefix: string; - name: string; - last_used_at: string | null; - created_at: string; - is_active: boolean; -} - -export interface N8nInstance { - id: string; - user_id: string; - instance_url: string; - api_key_encrypted: string; - is_active: boolean; - last_validated_at: string | null; - created_at: string; - updated_at: string; -} - -export interface UsageLog { - id: number; - user_id: string; - api_key_id: string | null; - tool_name: string; - status: 'success' | 'error' | 'rate_limited'; - error_message: string | null; - created_at: string; -} -``` - -#### 2.3 Create Rate Limiter Service - -**File:** `src/services/rate-limiter.ts` - -```typescript -interface RateLimitCounter { - count: number; - windowStart: number; -} - -export class RateLimiter { - private counters = new Map(); - private cleanupInterval: NodeJS.Timeout; - - constructor( - private limit: number = 100, // requests per window - private windowMs: number = 60000 // 1 minute - ) { - // Cleanup old counters every 5 minutes - this.cleanupInterval = setInterval(() => this.cleanup(), 300000); - } - - /** - * Check if request is within rate limit - * @param key Unique identifier (API key) - * @returns true if allowed, false if rate limited - */ - check(key: string): boolean { - const now = Date.now(); - let counter = this.counters.get(key); - - // Create new window if doesn't exist or expired - if (!counter || counter.windowStart < now - this.windowMs) { - counter = { - count: 0, - windowStart: now - }; - } - - counter.count++; - this.counters.set(key, counter); - - return counter.count <= this.limit; - } - - /** - * Get remaining requests for a key - */ - remaining(key: string): number { - const counter = this.counters.get(key); - if (!counter) return this.limit; - - const now = Date.now(); - if (counter.windowStart < now - this.windowMs) { - return this.limit; - } - - return Math.max(0, this.limit - counter.count); - } - - /** - * Reset rate limit for a key - */ - reset(key: string): void { - this.counters.delete(key); - } - - /** - * Cleanup expired counters - */ - private cleanup(): void { - const now = Date.now(); - for (const [key, counter] of this.counters.entries()) { - if (counter.windowStart < now - this.windowMs * 2) { - this.counters.delete(key); - } - } - } - - /** - * Shutdown cleanup interval - */ - destroy(): void { - clearInterval(this.cleanupInterval); - } -} -``` - -**Test:** -```typescript -// src/services/rate-limiter.test.ts -import { RateLimiter } from './rate-limiter'; - -describe('RateLimiter', () => { - test('should allow requests within limit', () => { - const limiter = new RateLimiter(3, 1000); - const key = 'test-key'; - - expect(limiter.check(key)).toBe(true); // 1 - expect(limiter.check(key)).toBe(true); // 2 - expect(limiter.check(key)).toBe(true); // 3 - expect(limiter.check(key)).toBe(false); // 4 - exceeded - }); - - test('should reset after window expires', async () => { - const limiter = new RateLimiter(2, 100); // 100ms window - const key = 'test-key'; - - limiter.check(key); // 1 - limiter.check(key); // 2 - expect(limiter.check(key)).toBe(false); // 3 - exceeded - - // Wait for window to expire - await new Promise(resolve => setTimeout(resolve, 150)); - - expect(limiter.check(key)).toBe(true); // New window - }); -}); -``` - -#### 2.4 Create Session Manager Service - -**File:** `src/services/session-manager.ts` - -```typescript -import fs from 'fs'; -import path from 'path'; -import { InstanceContext } from '../types'; - -export interface SessionData { - userId: string; - context: InstanceContext; - created: number; - lastAccess: number; - expires: number; -} - -export interface SessionOptions { - maxSessions: number; - ttl: number; // milliseconds - persistPath?: string; -} - -export class SessionManager { - private sessions = new Map(); - private backupInterval: NodeJS.Timeout | null = null; - - constructor(private options: SessionOptions) { - this.loadFromDisk(); - - // Backup to disk every minute if persistPath provided - if (options.persistPath) { - this.backupInterval = setInterval(() => { - this.backupToDisk(); - }, 60000); - } - - // Cleanup expired sessions every 5 minutes - setInterval(() => this.cleanup(), 300000); - } - - /** - * Get session by ID - */ - get(sessionId: string): SessionData | null { - const session = this.sessions.get(sessionId); - - if (!session) return null; - - // Check if expired - if (session.expires < Date.now()) { - this.sessions.delete(sessionId); - return null; - } - - // Update last access - session.lastAccess = Date.now(); - session.expires = Date.now() + this.options.ttl; - - return session; - } - - /** - * Create new session - */ - create(userId: string, context: InstanceContext): string { - // Enforce max sessions - if (this.sessions.size >= this.options.maxSessions) { - this.evictOldest(); - } - - const sessionId = this.generateSessionId(); - const now = Date.now(); - - this.sessions.set(sessionId, { - userId, - context, - created: now, - lastAccess: now, - expires: now + this.options.ttl - }); - - return sessionId; - } - - /** - * Delete session - */ - delete(sessionId: string): void { - this.sessions.delete(sessionId); - } - - /** - * Get all sessions for a user - */ - getByUser(userId: string): SessionData[] { - const result: SessionData[] = []; - for (const [_, session] of this.sessions) { - if (session.userId === userId && session.expires > Date.now()) { - result.push(session); - } - } - return result; - } - - /** - * Generate unique session ID - */ - private generateSessionId(): string { - return `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } - - /** - * Evict oldest session - */ - private evictOldest(): void { - let oldestId: string | null = null; - let oldestTime = Infinity; - - for (const [id, session] of this.sessions) { - if (session.lastAccess < oldestTime) { - oldestTime = session.lastAccess; - oldestId = id; - } - } - - if (oldestId) { - this.sessions.delete(oldestId); - } - } - - /** - * Cleanup expired sessions - */ - private cleanup(): void { - const now = Date.now(); - for (const [id, session] of this.sessions) { - if (session.expires < now) { - this.sessions.delete(id); - } - } - } - - /** - * Backup sessions to disk - */ - private backupToDisk(): void { - if (!this.options.persistPath) return; - - try { - const dirPath = this.options.persistPath; - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - const filePath = path.join(dirPath, 'sessions.json'); - const data = JSON.stringify(Array.from(this.sessions.entries())); - - fs.writeFileSync(filePath, data, 'utf8'); - } catch (error) { - console.error('Failed to backup sessions:', error); - } - } - - /** - * Load sessions from disk - */ - private loadFromDisk(): void { - if (!this.options.persistPath) return; - - try { - const filePath = path.join(this.options.persistPath, 'sessions.json'); - - if (fs.existsSync(filePath)) { - const data = fs.readFileSync(filePath, 'utf8'); - const entries = JSON.parse(data); - - // Only restore non-expired sessions - const now = Date.now(); - for (const [id, session] of entries) { - if (session.expires > now) { - this.sessions.set(id, session); - } - } - } - } catch (error) { - console.error('Failed to load sessions:', error); - } - } - - /** - * Shutdown manager - */ - destroy(): void { - if (this.backupInterval) { - clearInterval(this.backupInterval); - } - this.backupToDisk(); - } -} -``` - -#### 2.5 Create API Key Validator Service - -**File:** `src/services/api-key-validator.ts` - -```typescript -import bcrypt from 'bcryptjs'; -import { getSupabaseClient } from './database'; -import { decrypt } from './encryption'; -import { InstanceContext } from '../types'; - -export interface UserContext { - userId: string; - n8nUrl: string; - n8nApiKey: string; -} - -// In-memory cache for validated API keys (5 minute TTL) -interface CacheEntry { - context: UserContext; - expires: number; -} - -const apiKeyCache = new Map(); - -// Cleanup cache every 5 minutes -setInterval(() => { - const now = Date.now(); - for (const [key, entry] of apiKeyCache.entries()) { - if (entry.expires < now) { - apiKeyCache.delete(key); - } - } -}, 300000); - -/** - * Validates n8n-mcp API key and returns user context - * This performs the two-tier API key lookup: - * 1. Validate n8n-mcp API key (nmcp_xxx) - * 2. Fetch and decrypt user's n8n instance credentials - */ -export async function validateApiKey(apiKey: string): Promise { - // Check cache first - const cached = apiKeyCache.get(apiKey); - if (cached && cached.expires > Date.now()) { - return cached.context; - } - - const supabase = getSupabaseClient(); - - // Hash the provided API key - const keyHash = await bcrypt.hash(apiKey, 10); - - // Look up API key in database - const { data, error } = await supabase - .from('api_keys') - .select(` - id, - user_id, - is_active, - n8n_instances!inner ( - instance_url, - api_key_encrypted, - is_active - ) - `) - .eq('key_hash', keyHash) - .eq('is_active', true) - .single(); - - if (error || !data) { - throw new Error('Invalid API key'); - } - - // Check if n8n instance is active - const n8nInstance = Array.isArray(data.n8n_instances) - ? data.n8n_instances[0] - : data.n8n_instances; - - if (!n8nInstance || !n8nInstance.is_active) { - throw new Error('n8n instance not configured or inactive'); - } - - // Decrypt n8n API key (server-side only!) - let n8nApiKey: string; - try { - n8nApiKey = decrypt(n8nInstance.api_key_encrypted, data.user_id); - } catch (error) { - throw new Error('Failed to decrypt n8n credentials'); - } - - // Update last_used_at - await supabase - .from('api_keys') - .update({ last_used_at: new Date().toISOString() }) - .eq('id', data.id); - - // Create user context - const context: UserContext = { - userId: data.user_id, - n8nUrl: n8nInstance.instance_url, - n8nApiKey - }; - - // Cache for 5 minutes - apiKeyCache.set(apiKey, { - context, - expires: Date.now() + 300000 - }); - - return context; -} - -/** - * Clear cache for a specific API key - */ -export function clearApiKeyCache(apiKey: string): void { - apiKeyCache.delete(apiKey); -} - -/** - * Clear all cache - */ -export function clearAllCache(): void { - apiKeyCache.clear(); -} -``` - -#### 2.6 Modify HTTP Server for Multi-Tenant - -**File:** `src/http-server-single-session.ts` (modifications) - -```typescript -// Add these imports at the top -import { validateApiKey } from './services/api-key-validator'; -import { RateLimiter } from './services/rate-limiter'; -import { SessionManager } from './services/session-manager'; -import { getSupabaseClient } from './services/database'; - -// Initialize services (add after existing imports) -const rateLimiter = new RateLimiter(100, 60000); // 100 req/min -const sessionManager = new SessionManager({ - maxSessions: 1000, - ttl: 3600000, // 1 hour - persistPath: process.env.SESSION_PERSIST_PATH || './sessions' -}); - -// Add new method to HTTPServer class -private async handleMultiTenantRequest( - req: Request -): Promise { - // Extract API key from Authorization header - const authHeader = req.headers.get('Authorization'); - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return new Response('Missing or invalid Authorization header', { - status: 401, - headers: { 'Content-Type': 'text/plain' } - }); - } - - const apiKey = authHeader.substring(7); // Remove 'Bearer ' - - // Check rate limit - if (!rateLimiter.check(apiKey)) { - // Log rate limit event - try { - const supabase = getSupabaseClient(); - await supabase.from('usage_logs').insert({ - user_id: 'unknown', // We don't know user yet - tool_name: 'rate_limit', - status: 'rate_limited' - }); - } catch (error) { - console.error('Failed to log rate limit:', error); - } - - return new Response('Rate limit exceeded', { - status: 429, - headers: { - 'Content-Type': 'text/plain', - 'X-RateLimit-Limit': '100', - 'X-RateLimit-Remaining': '0', - 'Retry-After': '60' - } - }); - } - - // Validate API key and get user context - let userContext; - try { - userContext = await validateApiKey(apiKey); - } catch (error) { - return new Response('Unauthorized', { - status: 401, - headers: { 'Content-Type': 'text/plain' } - }); - } - - // Create InstanceContext (existing pattern!) - const instanceContext: InstanceContext = { - n8nApiUrl: userContext.n8nUrl, - n8nApiKey: userContext.n8nApiKey - }; - - // Handle MCP request with user's context - try { - const response = await this.handleMCPRequest(req, instanceContext); - - // Log successful usage - const supabase = getSupabaseClient(); - await supabase.from('usage_logs').insert({ - user_id: userContext.userId, - tool_name: this.extractToolName(req), - status: 'success' - }); - - return response; - } catch (error) { - // Log error - const supabase = getSupabaseClient(); - await supabase.from('usage_logs').insert({ - user_id: userContext.userId, - tool_name: this.extractToolName(req), - status: 'error', - error_message: error instanceof Error ? error.message : 'Unknown error' - }); - - throw error; - } -} - -// Helper method to extract tool name from request -private extractToolName(req: Request): string { - try { - const url = new URL(req.url); - return url.pathname.split('/').pop() || 'unknown'; - } catch { - return 'unknown'; - } -} - -// Modify existing handle() method to check for multi-tenant mode -async handle(req: Request): Promise { - const enableMultiTenant = process.env.ENABLE_MULTI_TENANT === 'true'; - - if (enableMultiTenant) { - return this.handleMultiTenantRequest(req); - } else { - // Existing single-tenant logic - return this.handleMCPRequest(req, this.defaultContext); - } -} -``` - -**Add to package.json dependencies:** -```json -{ - "dependencies": { - "@supabase/supabase-js": "^2.39.0", - "bcryptjs": "^2.4.3" - }, - "devDependencies": { - "@types/bcryptjs": "^2.4.6" - } -} -``` - -Install dependencies: -```bash -npm install @supabase/supabase-js bcryptjs -npm install -D @types/bcryptjs -``` - ---- - -### Day 4: Docker & Deployment Setup - -#### 4.1 Create Production Docker Compose - -**File:** `docker-compose.prod.yml` - -```yaml -version: '3.8' - -services: - caddy: - image: caddy:2-alpine - container_name: n8n-mcp-caddy - restart: always - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - - caddy_config:/config - networks: - - n8n-mcp-network - - n8n-mcp: - image: ghcr.io/czlonkowski/n8n-mcp:latest - container_name: n8n-mcp-app - restart: always - environment: - - SUPABASE_URL=${SUPABASE_URL} - - SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY} - - MASTER_ENCRYPTION_KEY=${MASTER_ENCRYPTION_KEY} - - SESSION_SECRET=${SESSION_SECRET} - - SESSION_PERSIST_PATH=/app/sessions - - NODE_ENV=production - - MCP_MODE=http - - PORT=3000 - - ENABLE_MULTI_TENANT=true - - RATE_LIMIT_REQUESTS=100 - volumes: - - ./data/nodes.db:/app/data/nodes.db:ro - - session_data:/app/sessions - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] - interval: 30s - timeout: 3s - retries: 3 - start_period: 10s - networks: - - n8n-mcp-network - -volumes: - caddy_data: - driver: local - caddy_config: - driver: local - session_data: - driver: local - -networks: - n8n-mcp-network: - driver: bridge -``` - -#### 4.2 Create Caddyfile - -**File:** `Caddyfile` - -``` -# Caddy configuration for n8n-mcp -{ - # Global options - email admin@n8n-mcp.com -} - -api.n8n-mcp.com { - # Reverse proxy to n8n-mcp container - reverse_proxy n8n-mcp:3000 { - # Health check - health_uri /health - health_interval 30s - health_timeout 5s - - # Headers - header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} - header_up X-Forwarded-Proto {scheme} - } - - # Global rate limiting (per IP) - rate_limit { - zone dynamic { - key {remote_host} - events 100 - window 1m - } - } - - # Logging - log { - output file /var/log/caddy/access.log { - roll_size 100mb - roll_keep 5 - } - format json - } - - # Error pages - handle_errors { - respond "{err.status_code} {err.status_text}" - } -} -``` - -#### 4.3 Create Dockerfile (if not exists) - -**File:** `Dockerfile` - -```dockerfile -# Build stage -FROM node:20-alpine AS builder - -WORKDIR /app - -# Copy package files -COPY package*.json ./ -COPY tsconfig.json ./ - -# Install dependencies -RUN npm ci - -# Copy source -COPY src ./src -COPY data ./data - -# Build -RUN npm run build - -# Production stage -FROM node:20-alpine - -WORKDIR /app - -# Install curl for healthcheck -RUN apk add --no-cache curl - -# Copy package files -COPY package*.json ./ - -# Install production dependencies only -RUN npm ci --omit=dev - -# Copy built files -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/data ./data - -# Create session directory -RUN mkdir -p /app/sessions && chown -R node:node /app/sessions - -# Use non-root user -USER node - -EXPOSE 3000 - -CMD ["node", "dist/index.js"] -``` - -#### 4.4 Create Deployment Script - -**File:** `scripts/deploy.sh` - -```bash -#!/bin/bash -set -e - -echo "๐Ÿš€ Deploying n8n-mcp to production..." - -# Build Docker image -echo "๐Ÿ“ฆ Building Docker image..." -docker build -t ghcr.io/czlonkowski/n8n-mcp:latest . - -# Push to registry (optional) -# docker push ghcr.io/czlonkowski/n8n-mcp:latest - -# Pull latest image on server -echo "โฌ‡๏ธ Pulling latest image..." -docker compose -f docker-compose.prod.yml pull - -# Stop containers -echo "๐Ÿ›‘ Stopping containers..." -docker compose -f docker-compose.prod.yml down - -# Start containers -echo "โ–ถ๏ธ Starting containers..." -docker compose -f docker-compose.prod.yml up -d - -# Wait for health check -echo "๐Ÿฅ Waiting for health check..." -sleep 10 - -# Verify -echo "โœ… Verifying deployment..." -curl -f https://api.n8n-mcp.com/health || { - echo "โŒ Health check failed!" - docker compose -f docker-compose.prod.yml logs n8n-mcp - exit 1 -} - -echo "โœ… Deployment successful!" -``` - -Make executable: -```bash -chmod +x scripts/deploy.sh -``` - -#### 4.5 Testing Multi-Tenant Locally - -**Create test script:** `scripts/test-multi-tenant.sh` - -```bash -#!/bin/bash - -# Test multi-tenant API key authentication - -API_URL="http://localhost:3000/mcp" -API_KEY="test-key-replace-with-real-key" - -# Test 1: Health check (no auth needed) -echo "Test 1: Health check..." -curl -s http://localhost:3000/health -echo "" - -# Test 2: Request without auth (should fail) -echo "Test 2: No auth (should fail)..." -curl -s -w "\nHTTP Status: %{http_code}\n" \ - -X POST $API_URL \ - -H "Content-Type: application/json" -echo "" - -# Test 3: Request with invalid key (should fail) -echo "Test 3: Invalid key (should fail)..." -curl -s -w "\nHTTP Status: %{http_code}\n" \ - -X POST $API_URL \ - -H "Authorization: Bearer invalid-key" \ - -H "Content-Type: application/json" -echo "" - -# Test 4: Valid request (should succeed) -echo "Test 4: Valid key (should succeed)..." -curl -s -w "\nHTTP Status: %{http_code}\n" \ - -X POST $API_URL \ - -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list" - }' -echo "" - -# Test 5: Rate limiting (send 101 requests) -echo "Test 5: Rate limiting (101 requests)..." -for i in {1..101}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST $API_URL \ - -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}') - - if [ "$STATUS" == "429" ]; then - echo "โœ… Rate limited at request $i" - break - fi -done -``` - ---- - -## Phase 2: Frontend Implementation - -**Goal:** User dashboard for signup, API key management, n8n config -**Time:** 5 days -**Assignee:** Frontend developer - -### Day 5-6: Authentication & Setup - -#### 5.1 Setup Supabase in Next.js - -**Install dependencies:** -```bash -cd ../n8n-mcp-landing -npm install @supabase/ssr @supabase/supabase-js -``` - -**Create environment file:** `.env.local` -```bash -NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxxxx -``` - -#### 5.2 Create Supabase Client Utils - -**File:** `src/lib/supabase/client.ts` - -```typescript -import { createBrowserClient } from '@supabase/ssr'; - -export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ); -} -``` - -**File:** `src/lib/supabase/server.ts` - -```typescript -import { createServerClient, type CookieOptions } from '@supabase/ssr'; -import { cookies } from 'next/headers'; - -export async function createClient() { - const cookieStore = await cookies(); - - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get(name: string) { - return cookieStore.get(name)?.value; - }, - set(name: string, value: string, options: CookieOptions) { - try { - cookieStore.set({ name, value, ...options }); - } catch (error) { - // Handle error - } - }, - remove(name: string, options: CookieOptions) { - try { - cookieStore.set({ name, value: '', ...options }); - } catch (error) { - // Handle error - } - }, - }, - } - ); -} -``` - -#### 5.3 Create Middleware for Auth Protection - -**File:** `src/middleware.ts` - -```typescript -import { createServerClient, type CookieOptions } from '@supabase/ssr'; -import { NextResponse, type NextRequest } from 'next/server'; - -export async function middleware(request: NextRequest) { - let response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get(name: string) { - return request.cookies.get(name)?.value; - }, - set(name: string, value: string, options: CookieOptions) { - request.cookies.set({ - name, - value, - ...options, - }); - response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - response.cookies.set({ - name, - value, - ...options, - }); - }, - remove(name: string, options: CookieOptions) { - request.cookies.set({ - name, - value: '', - ...options, - }); - response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - response.cookies.set({ - name, - value: '', - ...options, - }); - }, - }, - } - ); - - const { - data: { user }, - } = await supabase.auth.getUser(); - - // Protect dashboard routes - if (request.nextUrl.pathname.startsWith('/dashboard') && !user) { - return NextResponse.redirect(new URL('/login', request.url)); - } - - // Redirect to dashboard if already logged in - if ((request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup') && user) { - return NextResponse.redirect(new URL('/dashboard', request.url)); - } - - return response; -} - -export const config = { - matcher: ['/dashboard/:path*', '/login', '/signup'], -}; -``` - -#### 5.4 Create Authentication Pages - -**File:** `src/app/(auth)/signup/page.tsx` - -```typescript -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { createClient } from '@/lib/supabase/client'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; - -export default function SignupPage() { - const router = useRouter(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [fullName, setFullName] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [message, setMessage] = useState(''); - - async function handleSignup(e: React.FormEvent) { - e.preventDefault(); - setLoading(true); - setError(''); - setMessage(''); - - const supabase = createClient(); - - const { error } = await supabase.auth.signUp({ - email, - password, - options: { - data: { - full_name: fullName, - }, - emailRedirectTo: `${location.origin}/auth/callback`, - }, - }); - - if (error) { - setError(error.message); - } else { - setMessage('Check your email for the confirmation link!'); - } - - setLoading(false); - } - - return ( -
-
-
-

Sign up for n8n-mcp

-

- Join 471 users already building AI workflows -

-
- -
-
- - setFullName(e.target.value)} - /> -
- -
- - setEmail(e.target.value)} - /> -
- -
- - setPassword(e.target.value)} - /> -

- Must be at least 8 characters -

-
- - {error && ( -
- {error} -
- )} - - {message && ( -
- {message} -
- )} - - -
- -
- Already have an account?{' '} - - Log in - -
-
-
- ); -} -``` - -**File:** `src/app/(auth)/login/page.tsx` - -```typescript -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { createClient } from '@/lib/supabase/client'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; - -export default function LoginPage() { - const router = useRouter(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - async function handleLogin(e: React.FormEvent) { - e.preventDefault(); - setLoading(true); - setError(''); - - const supabase = createClient(); - - const { error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (error) { - setError(error.message); - setLoading(false); - } else { - router.push('/dashboard'); - } - } - - return ( -
-
-
-

Welcome back

-

- Log in to access your n8n-mcp dashboard -

-
- -
-
- - setEmail(e.target.value)} - /> -
- -
- - setPassword(e.target.value)} - /> -
- - {error && ( -
- {error} -
- )} - - -
- -
- Don't have an account?{' '} - - Sign up - -
-
-
- ); -} -``` - -**File:** `src/app/auth/callback/route.ts` - -```typescript -import { createClient } from '@/lib/supabase/server'; -import { NextResponse } from 'next/server'; - -export async function GET(request: Request) { - const { searchParams, origin } = new URL(request.url); - const code = searchParams.get('code'); - const next = searchParams.get('next') ?? '/dashboard'; - - if (code) { - const supabase = await createClient(); - const { error } = await supabase.auth.exchangeCodeForSession(code); - if (!error) { - return NextResponse.redirect(`${origin}${next}`); - } - } - - return NextResponse.redirect(`${origin}/login`); -} -``` - ---- - -### Day 7-8: Dashboard Implementation - -#### 7.1 Create Dashboard Layout - -**File:** `src/app/(dashboard)/layout.tsx` - -```typescript -import { createClient } from '@/lib/supabase/server'; -import { redirect } from 'next/navigation'; -import Link from 'next/link'; - -export default async function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - redirect('/login'); - } - - async function signOut() { - 'use server'; - const supabase = await createClient(); - await supabase.auth.signOut(); - redirect('/'); - } - - return ( -
- {/* Sidebar */} - - - {/* Main content */} -
{children}
-
- ); -} -``` - -#### 7.2 Dashboard Overview Page - -**File:** `src/app/(dashboard)/dashboard/page.tsx` - -```typescript -import { createClient } from '@/lib/supabase/server'; -import { Card } from '@/components/ui/card'; - -export default async function DashboardPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - // Fetch stats - const { count: apiKeyCount } = await supabase - .from('api_keys') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user!.id) - .eq('is_active', true); - - const { count: usageCount } = await supabase - .from('usage_logs') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user!.id); - - const { data: n8nInstance } = await supabase - .from('n8n_instances') - .select('instance_url, is_active') - .eq('user_id', user!.id) - .single(); - - return ( -
-
-

Dashboard

-

- Welcome to your n8n-mcp control panel -

-
- -
- -

API Keys

-

{apiKeyCount || 0}

-
- - -

Requests Today

-

{usageCount || 0}

-
- - -

n8n Status

-

- {n8nInstance?.is_active ? 'โœ…' : 'โŒ'} -

-
-
- - {!n8nInstance && ( - -

โš ๏ธ Action Required

-

- You need to configure your n8n instance before using the service. -

- - Configure n8n โ†’ - -
- )} -
- ); -} -``` - -#### 7.3 API Key Management Page - -**File:** `src/app/(dashboard)/api-keys/page.tsx` - -```typescript -import { createClient } from '@/lib/supabase/server'; -import { ApiKeyList } from '@/components/api-key-list'; -import { CreateApiKeyButton } from '@/components/create-api-key-button'; - -export default async function ApiKeysPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - const { data: apiKeys } = await supabase - .from('api_keys') - .select('*') - .eq('user_id', user!.id) - .order('created_at', { ascending: false }); - - return ( -
-
-
-

API Keys

-

- Manage your n8n-mcp API keys for MCP clients -

-
- -
- - -
- ); -} -``` - -**File:** `src/components/create-api-key-button.tsx` - -```typescript -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { generateApiKey } from '@/app/(dashboard)/api-keys/actions'; - -export function CreateApiKeyButton() { - const router = useRouter(); - const [open, setOpen] = useState(false); - const [name, setName] = useState(''); - const [loading, setLoading] = useState(false); - const [generatedKey, setGeneratedKey] = useState(null); - - async function handleCreate() { - setLoading(true); - try { - const result = await generateApiKey(name); - setGeneratedKey(result.key); - } catch (error) { - alert('Failed to generate API key'); - } finally { - setLoading(false); - } - } - - function handleClose() { - setOpen(false); - setName(''); - setGeneratedKey(null); - router.refresh(); - } - - return ( - - - - - - - Create API Key - - - {!generatedKey ? ( -
-
- - setName(e.target.value)} - /> -

- A friendly name to identify this key -

-
- - -
- ) : ( -
-
-

- โš ๏ธ Save this key securely! -

-

- You won't be able to see it again. -

-
- -
- -
- {generatedKey} -
-
- - - - -
- )} -
-
- ); -} -``` - -**File:** `src/app/(dashboard)/api-keys/actions.ts` - -```typescript -'use server'; - -import { createClient } from '@/lib/supabase/server'; -import crypto from 'crypto'; -import bcrypt from 'bcryptjs'; -import { revalidatePath } from 'next/cache'; - -export async function generateApiKey(name: string) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) throw new Error('Not authenticated'); - - // Generate secure random key - const key = crypto.randomBytes(32).toString('base64url'); - const fullKey = `nmcp_${key}`; - const hash = await bcrypt.hash(fullKey, 10); - const prefix = `nmcp_${key.substring(0, 8)}...`; - - // Store in database - const { data, error } = await supabase - .from('api_keys') - .insert({ - user_id: user.id, - key_hash: hash, - key_prefix: prefix, - name: name, - }) - .select() - .single(); - - if (error) throw error; - - revalidatePath('/dashboard/api-keys'); - - return { key: fullKey, id: data.id }; -} - -export async function revokeApiKey(id: string) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) throw new Error('Not authenticated'); - - const { error } = await supabase - .from('api_keys') - .update({ is_active: false }) - .eq('id', id) - .eq('user_id', user.id); - - if (error) throw error; - - revalidatePath('/dashboard/api-keys'); -} -``` - -**File:** `src/components/api-key-list.tsx` - -```typescript -'use client'; - -import { Card } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { revokeApiKey } from '@/app/(dashboard)/api-keys/actions'; - -interface ApiKey { - id: string; - name: string; - key_prefix: string; - created_at: string; - last_used_at: string | null; - is_active: boolean; -} - -export function ApiKeyList({ apiKeys }: { apiKeys: ApiKey[] }) { - async function handleRevoke(id: string) { - if (confirm('Are you sure you want to revoke this API key?')) { - await revokeApiKey(id); - } - } - - if (apiKeys.length === 0) { - return ( - -

No API keys yet. Create your first one!

-
- ); - } - - return ( -
- {apiKeys.map((key) => ( - -
-
-

{key.name}

-

- {key.key_prefix} -

-

- Created: {new Date(key.created_at).toLocaleDateString()} - {key.last_used_at && ( - <> ยท Last used: {new Date(key.last_used_at).toLocaleString()} - )} -

-
- -
- {key.is_active ? ( - โ— Active - ) : ( - โ— Revoked - )} - {key.is_active && ( - - )} -
-
-
- ))} -
- ); -} -``` - -#### 7.4 n8n Configuration Page - -**File:** `src/app/(dashboard)/n8n-config/page.tsx` - -```typescript -import { createClient } from '@/lib/supabase/server'; -import { N8nConfigForm } from '@/components/n8n-config-form'; - -export default async function N8nConfigPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - const { data: instance } = await supabase - .from('n8n_instances') - .select('instance_url, is_active') - .eq('user_id', user!.id) - .single(); - - return ( -
-
-

n8n Configuration

-

- Connect your n8n instance to n8n-mcp -

-
- - -
- ); -} -``` - -**File:** `src/components/n8n-config-form.tsx` - -```typescript -'use client'; - -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card } from '@/components/ui/card'; -import { saveN8nConfig, testN8nConnection } from '@/app/(dashboard)/n8n-config/actions'; - -interface N8nConfigFormProps { - currentInstance: { - instance_url: string; - is_active: boolean; - } | null; -} - -export function N8nConfigForm({ currentInstance }: N8nConfigFormProps) { - const [instanceUrl, setInstanceUrl] = useState( - currentInstance?.instance_url || '' - ); - const [apiKey, setApiKey] = useState(''); - const [testing, setTesting] = useState(false); - const [saving, setSaving] = useState(false); - const [testResult, setTestResult] = useState<'success' | 'error' | null>(null); - const [error, setError] = useState(''); - - async function handleTest() { - setTesting(true); - setError(''); - setTestResult(null); - - try { - const result = await testN8nConnection(instanceUrl, apiKey); - setTestResult('success'); - } catch (err) { - setTestResult('error'); - setError(err instanceof Error ? err.message : 'Connection failed'); - } finally { - setTesting(false); - } - } - - async function handleSave() { - setSaving(true); - setError(''); - - try { - await saveN8nConfig(instanceUrl, apiKey); - alert('Configuration saved successfully!'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save'); - } finally { - setSaving(false); - } - } - - return ( - -
-
- - setInstanceUrl(e.target.value)} - /> -

- The URL of your n8n instance -

-
- -
- - setApiKey(e.target.value)} - /> -

- Find this in your n8n Settings โ†’ API -

-
- - {testResult && ( -
- {testResult === 'success' ? 'โœ… Connection successful!' : `โŒ ${error}`} -
- )} - -
- - - -
- - {currentInstance && ( -
-

- Current instance:{' '} - {currentInstance.instance_url} - - {currentInstance.is_active ? 'โœ… Active' : 'โŒ Inactive'} - -

-
- )} -
-
- ); -} -``` - -**File:** `src/app/(dashboard)/n8n-config/actions.ts` - -```typescript -'use server'; - -import { createClient } from '@/lib/supabase/server'; -import crypto from 'crypto'; - -// Simplified encryption (in production, use the backend's encryption) -function encrypt(text: string, userId: string): string { - // This is placeholder - in production, this should match backend encryption - // For MVP, we'll use a simple base64 encoding as Supabase will be our secure storage - return Buffer.from(text).toString('base64'); -} - -export async function testN8nConnection( - instanceUrl: string, - apiKey: string -): Promise { - try { - const response = await fetch(`${instanceUrl}/api/v1/workflows`, { - headers: { - 'X-N8N-API-KEY': apiKey, - }, - }); - - if (!response.ok) { - throw new Error('Invalid credentials or instance URL'); - } - - return true; - } catch (error) { - throw new Error('Failed to connect to n8n instance'); - } -} - -export async function saveN8nConfig( - instanceUrl: string, - apiKey: string -) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) throw new Error('Not authenticated'); - - // Test connection first - await testN8nConnection(instanceUrl, apiKey); - - // Encrypt API key (simplified for MVP) - const encryptedKey = encrypt(apiKey, user.id); - - // Upsert configuration - const { error } = await supabase.from('n8n_instances').upsert( - { - user_id: user.id, - instance_url: instanceUrl, - api_key_encrypted: encryptedKey, - is_active: true, - last_validated_at: new Date().toISOString(), - }, - { - onConflict: 'user_id,instance_url', - } - ); - - if (error) throw error; -} -``` - ---- - -### Day 9: Polish & Deployment - -#### 9.1 Add Usage Stats Page - -**File:** `src/app/(dashboard)/usage/page.tsx` - -```typescript -import { createClient } from '@/lib/supabase/server'; -import { Card } from '@/components/ui/card'; - -export default async function UsagePage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - // Get recent usage - const { data: recentLogs } = await supabase - .from('usage_logs') - .select('tool_name, status, created_at') - .eq('user_id', user!.id) - .order('created_at', { ascending: false }) - .limit(50); - - // Get stats - const { count: totalRequests } = await supabase - .from('usage_logs') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user!.id); - - const { count: todayRequests } = await supabase - .from('usage_logs') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user!.id) - .gte('created_at', new Date().toISOString().split('T')[0]); - - return ( -
-
-

Usage Statistics

-

- Track your n8n-mcp API usage -

-
- -
- -

Total Requests

-

{totalRequests || 0}

-
- - -

Today's Requests

-

{todayRequests || 0}

-
-
- - -

Recent Activity

-
- {recentLogs && recentLogs.length > 0 ? ( - recentLogs.map((log, i) => ( -
- {log.tool_name} -
- - {log.status} - - - {new Date(log.created_at).toLocaleTimeString()} - -
-
- )) - ) : ( -

No activity yet

- )} -
-
-
- ); -} -``` - -#### 9.2 Deploy Frontend to Vercel - -```bash -# In n8n-mcp-landing directory -cd ../n8n-mcp-landing - -# Install Vercel CLI -npm install -g vercel - -# Login to Vercel -vercel login - -# Deploy -vercel --prod - -# Set environment variables in Vercel dashboard -# NEXT_PUBLIC_SUPABASE_URL -# NEXT_PUBLIC_SUPABASE_ANON_KEY -``` - -#### 9.3 Final Backend Build & Test - -```bash -# In n8n-mcp directory -cd ../n8n-mcp - -# Run all tests -npm test - -# Type check -npm run typecheck - -# Build -npm run build - -# Test Docker build -docker build -t n8n-mcp:test . - -# Test locally with docker-compose -docker-compose -f docker-compose.prod.yml up -d - -# Verify -curl http://localhost:3000/health -``` - ---- - -## Phase 3: Testing & Launch - -**Goal:** Test thoroughly and launch to 471 waitlist users -**Time:** 3 days -**Assignee:** All team members - -### Day 10: Multi-User & Platform Testing - -#### 10.1 Multi-User Testing - -**Create 2+ test accounts:** - -```bash -# Test User 1 -Email: test1@example.com -n8n Instance: https://test-n8n-1.com -API Key: generated via dashboard - -# Test User 2 -Email: test2@example.com -n8n Instance: https://test-n8n-2.com -API Key: generated via dashboard -``` - -**Test isolation:** - -1. User 1 creates API key -2. User 2 creates API key -3. Verify User 1 cannot see User 2's keys -4. Make MCP requests with both keys -5. Verify usage logs are isolated -6. Try User 1's key with User 2's data โ†’ should fail - -**Checklist:** -- [ ] Users can only see their own API keys -- [ ] Users can only see their own n8n config -- [ ] Users can only see their own usage logs -- [ ] Cross-user API keys don't work -- [ ] Rate limiting works per user - -#### 10.2 Platform Testing - -**Test all MCP clients:** - -**Claude Desktop:** -```json -// ~/Library/Application Support/Claude/claude_desktop_config.json (Mac) -// %APPDATA%\Claude\claude_desktop_config.json (Windows) -{ - "mcpServers": { - "n8n-mcp": { - "url": "https://api.n8n-mcp.com/mcp", - "authentication": { - "type": "bearer", - "token": "nmcp_your_key_here" - } - } - } -} -``` - -Test commands: -- "List n8n nodes" -- "Search for Slack nodes" -- "Get node info for HTTP Request" -- "Create a workflow with Webhook trigger" - -**Cursor:** -```json -// ~/.cursor/mcp.json -{ - "servers": { - "n8n-mcp": { - "url": "https://api.n8n-mcp.com/mcp", - "headers": { - "Authorization": "Bearer nmcp_your_key_here" - } - } - } -} -``` - -**Windsurf:** -```json -// Settings > MCP Servers -{ - "serverUrl": "https://api.n8n-mcp.com/mcp", - "authToken": "nmcp_your_key_here" -} -``` - -**Checklist:** -- [ ] Claude Desktop connects successfully -- [ ] Cursor connects successfully -- [ ] Windsurf connects successfully -- [ ] All MCP tools work in each client -- [ ] Rate limiting headers appear -- [ ] Errors are descriptive - -#### 10.3 Load Testing - -**Install siege:** -```bash -brew install siege # Mac -sudo apt install siege # Linux -``` - -**Create test script:** `scripts/load-test.sh` - -```bash -#!/bin/bash - -API_URL="https://api.n8n-mcp.com/mcp" -API_KEY="nmcp_test_key" - -# Create URLs file -cat > /tmp/urls.txt << EOF -$API_URL POST Content-Type: application/json -Authorization: Bearer $API_KEY -{"jsonrpc":"2.0","id":1,"method":"tools/list"} -EOF - -# Run load test: 100 concurrent users, 1 minute -siege -c 100 -t 1M -f /tmp/urls.txt - -# Expected results: -# - Availability: 100% -# - Response time: <500ms average -# - Some 429 rate limit responses (expected) -``` - -**Checklist:** -- [ ] Server handles 100 concurrent users -- [ ] Average response time <500ms -- [ ] No crashes or errors -- [ ] Rate limiting kicks in appropriately -- [ ] CPU usage <80% -- [ ] Memory usage <4GB - ---- - -### Day 11: Documentation & Email Campaign - -#### 11.1 Create User Documentation - -**File:** `docs/user-guide.md` - -```markdown -# n8n-mcp User Guide - -## Getting Started - -### 1. Sign Up - -Visit https://www.n8n-mcp.com and click "Sign Up". -Enter your email and create a password. -Verify your email address. - -### 2. Configure Your n8n Instance - -1. Go to Dashboard โ†’ n8n Configuration -2. Enter your n8n instance URL (e.g., https://your-n8n.com) -3. Enter your n8n API key (find in n8n Settings โ†’ API) -4. Click "Test Connection" -5. Click "Save Configuration" - -### 3. Create an API Key - -1. Go to Dashboard โ†’ API Keys -2. Click "Create API Key" -3. Enter a name (e.g., "Claude Desktop") -4. Copy the generated key (you won't see it again!) - -### 4. Configure Your MCP Client - -#### Claude Desktop - -File location: -- Mac: `~/Library/Application Support/Claude/claude_desktop_config.json` -- Windows: `%APPDATA%\Claude\claude_desktop_config.json` - -Add this configuration: - -\`\`\`json -{ - "mcpServers": { - "n8n-mcp": { - "url": "https://api.n8n-mcp.com/mcp", - "authentication": { - "type": "bearer", - "token": "nmcp_your_key_here" - } - } - } -} -\`\`\` - -Restart Claude Desktop. - -#### Cursor - -File: `~/.cursor/mcp.json` - -\`\`\`json -{ - "servers": { - "n8n-mcp": { - "url": "https://api.n8n-mcp.com/mcp", - "headers": { - "Authorization": "Bearer nmcp_your_key_here" - } - } - } -} -\`\`\` - -Restart Cursor. - -## Usage - -Try these commands: -- "List all n8n nodes" -- "Search for Slack nodes" -- "How do I use the HTTP Request node?" -- "Create a workflow that triggers on webhook" - -## Troubleshooting - -### "Unauthorized" Error -- Check your API key is correct -- Verify the key is active in your dashboard -- Ensure n8n instance is configured - -### "Rate Limit Exceeded" -- Free tier: 100 requests/minute -- Wait 1 minute and try again -- Contact us for higher limits - -### Connection Timeout -- Verify n8n instance is accessible -- Check your n8n API key is valid -- Test connection in dashboard - -## Support - -- Email: support@n8n-mcp.com -- Discord: [Join our community] -- GitHub: https://github.com/czlonkowski/n8n-mcp -``` - -#### 11.2 Create Email Templates - -**Waitlist Invitation Email:** - -```html -Subject: ๐ŸŽ‰ You're invited to n8n-mcp hosted service! - -Hi {{name}}, - -You're one of 471 users from our waitlist with early access to the hosted n8n-mcp service! - -What is n8n-mcp? -Connect your n8n workflows to Claude, Cursor, Windsurf, and any MCP-compatible AI assistant. - -Getting Started: -1. Sign up: https://www.n8n-mcp.com/signup?ref=waitlist -2. Configure your n8n instance -3. Generate an API key -4. Add to your MCP client -5. Start building AI-powered workflows! - -Free for Waitlist Users: -โœ… 100 requests/minute -โœ… All MCP tools -โœ… Community support -โœ… No credit card required - -Need help? Reply to this email or join our Discord. - -Happy automating! -The n8n-mcp Team - ---- -Didn't sign up for the waitlist? Ignore this email. -``` - -#### 11.3 Prepare Launch Checklist - -**File:** `docs/launch-checklist.md` - -```markdown -# Launch Checklist - -## Pre-Launch (Complete before sending emails) - -### Infrastructure -- [ ] Production server running -- [ ] SSL certificates working -- [ ] DNS configured correctly -- [ ] Health endpoint responding -- [ ] Monitoring enabled - -### Database -- [ ] Schema deployed -- [ ] RLS policies active -- [ ] Backups enabled -- [ ] Test data removed - -### Backend -- [ ] Multi-tenant mode enabled -- [ ] API key validation working -- [ ] Rate limiting functional -- [ ] Encryption working -- [ ] All tests passing - -### Frontend -- [ ] Deployed to production -- [ ] Auth flow working -- [ ] API key generation works -- [ ] n8n config saves correctly -- [ ] Usage stats displaying - -### Testing -- [ ] Multi-user isolation verified -- [ ] All MCP clients tested -- [ ] Load test passed -- [ ] Security audit done - -### Documentation -- [ ] User guide published -- [ ] Platform setup guides ready -- [ ] Troubleshooting docs complete -- [ ] Email templates ready - -## Launch Day - -### Morning -- [ ] Final smoke test -- [ ] Backup database -- [ ] Monitor logs -- [ ] Support email ready - -### Soft Launch (First 50 users) -- [ ] Send email to first 50 -- [ ] Monitor signups -- [ ] Watch for errors -- [ ] Respond to questions - -### Full Launch (Next 421 users) -- [ ] Verify soft launch successful -- [ ] Send remaining emails -- [ ] Monitor onboarding funnel -- [ ] Track activation rate - -## Post-Launch - -### First 24 Hours -- [ ] Monitor error rates -- [ ] Check server resources -- [ ] Respond to support emails -- [ ] Fix critical bugs - -### First Week -- [ ] Analyze usage patterns -- [ ] Collect user feedback -- [ ] Identify pain points -- [ ] Plan improvements -``` - ---- - -### Day 12: Launch! - -#### 12.1 Pre-Launch Verification - -```bash -# Run final checks -./scripts/pre-launch-check.sh -``` - -**File:** `scripts/pre-launch-check.sh` - -```bash -#!/bin/bash - -echo "๐Ÿ” Running pre-launch checks..." - -# Check health endpoint -echo "1. Health check..." -STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://api.n8n-mcp.com/health) -if [ "$STATUS" == "200" ]; then - echo "โœ… Health check passed" -else - echo "โŒ Health check failed: $STATUS" - exit 1 -fi - -# Check SSL -echo "2. SSL certificate..." -openssl s_client -connect api.n8n-mcp.com:443 -servername api.n8n-mcp.com /dev/null | grep "Verify return code: 0" -if [ $? -eq 0 ]; then - echo "โœ… SSL valid" -else - echo "โŒ SSL invalid" - exit 1 -fi - -# Check frontend -echo "3. Frontend check..." -STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://www.n8n-mcp.com) -if [ "$STATUS" == "200" ]; then - echo "โœ… Frontend accessible" -else - echo "โŒ Frontend failed: $STATUS" - exit 1 -fi - -# Check database connection -echo "4. Database check..." -# (Add Supabase connectivity test) - -echo "" -echo "โœ… All pre-launch checks passed!" -echo "Ready to launch! ๐Ÿš€" -``` - -#### 12.2 Launch Procedure - -**9:00 AM - Soft Launch (50 users)** - -```bash -# Send to first 50 waitlist users -# Monitor: https://dashboard.n8n-mcp.com/analytics - -# Watch logs -docker compose -f docker-compose.prod.yml logs -f n8n-mcp - -# Monitor server -htop -``` - -**11:00 AM - Check Results** - -Metrics to check: -- Signup rate: Target 70% (35/50) -- Activation rate: Target 60% (21/50) -- Error rate: Target <5% -- Support emails: Respond within 1 hour - -**2:00 PM - Full Launch (421 users)** - -If soft launch successful: -```bash -# Send to remaining waitlist -# Continue monitoring -``` - -#### 12.3 Monitoring During Launch - -**Real-time monitoring:** - -```bash -# Server resources -watch -n 5 'top -b -n 1 | head -20' - -# Request rate -watch -n 5 'docker compose logs n8n-mcp | grep "POST /mcp" | tail -20' - -# Error rate -watch -n 5 'docker compose logs n8n-mcp | grep "ERROR" | tail -10' - -# Database connections -# Check Supabase dashboard -``` - -**Key metrics:** -- Server CPU: Should stay <60% -- Memory: Should stay <4GB -- Response time: Should be <500ms -- Error rate: Should be <2% - ---- - -## Troubleshooting - -### Common Issues - -#### Issue 1: "Unauthorized" Errors - -**Symptoms:** -- Users getting 401 errors -- API key validation failing - -**Debug:** -```bash -# Check API key in database -# Via Supabase SQL Editor: -SELECT * FROM api_keys WHERE key_prefix LIKE 'nmcp_%'; - -# Check if user has n8n instance configured -SELECT * FROM n8n_instances WHERE user_id = 'xxx'; - -# Check backend logs -docker compose logs n8n-mcp | grep "validateApiKey" -``` - -**Solutions:** -- Verify API key was copied correctly -- Check n8n instance is configured -- Verify encryption key is set -- Test API key generation flow - -#### Issue 2: Rate Limiting Too Aggressive - -**Symptoms:** -- Users hitting rate limits quickly -- 429 errors frequent - -**Debug:** -```bash -# Check rate limit settings -docker compose exec n8n-mcp env | grep RATE_LIMIT - -# Check logs -docker compose logs n8n-mcp | grep "Rate limit exceeded" -``` - -**Solutions:** -```typescript -// Adjust in src/services/rate-limiter.ts -const rateLimiter = new RateLimiter(200, 60000); // Increase to 200/min - -// Or set via environment -RATE_LIMIT_REQUESTS=200 -``` - -#### Issue 3: n8n Connection Failures - -**Symptoms:** -- "Failed to decrypt credentials" -- "n8n instance not accessible" - -**Debug:** -```bash -# Test n8n connectivity -curl -H "X-N8N-API-KEY: xxx" https://user-n8n.com/api/v1/workflows - -# Check encryption -# Verify MASTER_ENCRYPTION_KEY is set correctly -``` - -**Solutions:** -- Verify n8n instance is publicly accessible -- Check n8n API key is valid -- Test encryption/decryption manually -- Verify firewall rules - -#### Issue 4: High Memory Usage - -**Symptoms:** -- Server running out of memory -- Docker containers being killed - -**Debug:** -```bash -# Check memory usage -docker stats - -# Check session count -# Add logging to SessionManager -``` - -**Solutions:** -```typescript -// Reduce session TTL -const sessionManager = new SessionManager({ - maxSessions: 500, // Reduce from 1000 - ttl: 1800000, // 30 minutes instead of 1 hour -}); - -// Or add to server -# Upgrade to CPX41 (8 vCPU, 16GB) - โ‚ฌ26/mo -``` - -#### Issue 5: Database Connection Errors - -**Symptoms:** -- "Could not connect to Supabase" -- Queries timing out - -**Debug:** -```bash -# Check Supabase dashboard -# Connection pooling status - -# Check environment variables -docker compose exec n8n-mcp env | grep SUPABASE -``` - -**Solutions:** -- Verify SUPABASE_SERVICE_KEY is correct -- Check Supabase project is not paused -- Upgrade to Supabase Pro if hitting limits -- Add connection retry logic - -### Debug Commands - -**Check container status:** -```bash -docker compose ps -docker compose logs -f n8n-mcp -docker compose logs -f caddy -``` - -**Test API endpoint:** -```bash -# Health check -curl https://api.n8n-mcp.com/health - -# Test with API key -curl -X POST https://api.n8n-mcp.com/mcp \ - -H "Authorization: Bearer nmcp_xxx" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -``` - -**Check database:** -```sql --- Via Supabase SQL Editor - --- Count users -SELECT COUNT(*) FROM users; - --- Count active API keys -SELECT COUNT(*) FROM api_keys WHERE is_active = true; - --- Check recent usage -SELECT user_id, tool_name, status, created_at -FROM usage_logs -ORDER BY created_at DESC -LIMIT 20; - --- Find rate limited requests -SELECT user_id, COUNT(*) as rate_limited_count -FROM usage_logs -WHERE status = 'rate_limited' -AND created_at > NOW() - INTERVAL '1 hour' -GROUP BY user_id -ORDER BY rate_limited_count DESC; -``` - ---- - -## Rollback Procedures - -### Scenario 1: Critical Backend Bug - -**If you need to rollback backend:** - -```bash -# SSH to server -ssh root@your-server - -# Stop containers -cd /opt/n8n-mcp -docker compose -f docker-compose.prod.yml down - -# Revert to previous image -docker pull ghcr.io/czlonkowski/n8n-mcp:previous - -# Update docker-compose to use previous image -# Or checkout previous git commit -git log # Find previous working commit -git checkout - -# Redeploy -docker compose -f docker-compose.prod.yml up -d - -# Verify -curl https://api.n8n-mcp.com/health -``` - -**Notify users:** -``` -Subject: Brief Service Interruption - -We experienced a technical issue and had to rollback to a previous version. -Service is now restored. We apologize for any inconvenience. -``` - -### Scenario 2: Database Schema Issue - -**If schema migration causes issues:** - -```sql --- Via Supabase SQL Editor - --- Rollback last migration -BEGIN; - --- Drop new columns/tables (if added) -DROP TABLE IF EXISTS new_table; -ALTER TABLE existing_table DROP COLUMN IF EXISTS new_column; - --- Restore data from backup (if needed) --- Contact Supabase support for restore - -COMMIT; -``` - -### Scenario 3: Frontend Issue - -**If frontend has bugs:** - -```bash -# Rollback Vercel deployment -vercel rollback - -# Or deploy previous version -git checkout -vercel --prod -``` - -### Scenario 4: Complete Outage - -**If entire service is down:** - -1. **Immediate Actions:** - - Post status update (Twitter, Discord) - - Email all active users - - Disable signup temporarily - -2. **Investigation:** -```bash -# Check all services -docker compose ps -docker compose logs --tail=100 - -# Check server resources -htop -df -h - -# Check Supabase status -# Visit Supabase dashboard -``` - -3. **Recovery:** -```bash -# Restart all services -docker compose -f docker-compose.prod.yml restart - -# If that doesn't work, full redeploy -docker compose down -docker compose pull -docker compose up -d -``` - -4. **Post-mortem:** - - Document what happened - - Identify root cause - - Implement fixes - - Update runbook - ---- - -## Success Metrics - -### Week 1 Targets - -| Metric | Target | How to Measure | -|--------|--------|----------------| -| Signups | 300/471 (64%) | Supabase users table | -| Activation | 70% | Users with API key + n8n config | -| First MCP Call | 60% | Users with usage_logs entry | -| Error Rate | <2% | usage_logs WHERE status='error' | -| Support Response | <2 hours | Email metrics | - -### Week 4 Targets - -| Metric | Target | How to Measure | -|--------|--------|----------------| -| Day 7 Retention | 40% | Active users 7 days after signup | -| Day 30 Retention | 25% | Active users after 30 days | -| Avg Requests/User/Day | >5 | usage_logs COUNT / users | -| Platform Distribution | Track | % Claude vs Cursor vs Windsurf | -| User Satisfaction | >4/5 | Survey after 7 days | - ---- - -## Next Steps After MVP - -### Post-MVP Release 1: Analytics (Weeks 5-6) - -- Detailed usage dashboard -- Tool usage breakdown -- Performance metrics -- Error tracking (Sentry) - -### Post-MVP Release 2: Paid Tiers (Weeks 7-10) - -- Stripe integration -- Plan management -- Billing dashboard -- Upgrade/downgrade flows - -### Post-MVP Release 3: Advanced Features (Weeks 11-12) - -- Team collaboration -- Shared workflows -- API key rotation -- Custom alerts - ---- - -**End of Implementation Guide** - -This guide provides complete step-by-step instructions for implementing the n8n-mcp MVP in 2.5 weeks. Follow each phase carefully, test thoroughly, and launch with confidence! - -For questions or issues during implementation: -- Check troubleshooting section -- Review existing code in n8n-mcp repo -- Consult MVP_DEPLOYMENT_PLAN_SIMPLIFIED.md - -Good luck with your launch! ๐Ÿš€ \ No newline at end of file diff --git a/MVP_DEPLOYMENT_PLAN.md b/MVP_DEPLOYMENT_PLAN.md deleted file mode 100644 index 9925873..0000000 --- a/MVP_DEPLOYMENT_PLAN.md +++ /dev/null @@ -1,1464 +0,0 @@ -# n8n-mcp Hosted Service: MVP Deployment Plan - -**Project:** Multi-Tenant n8n-MCP Service on Hetzner -**Domain:** www.n8n-mcp.com (already owned) -**Goal:** Launch MVP to 471 waitlist users (free tier) -**Timeline:** 3-4 weeks to MVP launch -**Date:** 2025-10-11 -**Version:** 3.0 - MVP Focus - ---- - -## Executive Summary - -### MVP Scope (Waitlist Launch - No Payments) - -**What We're Building:** -- Multi-tenant n8n-mcp service hosted on Hetzner -- User authentication and dashboard (Supabase) -- API key management -- Per-user n8n instance configuration -- Support for Claude Desktop, Cursor, Windsurf, and all MCP clients -- Free tier for all 471 waitlist users - -**What We're NOT Building (Post-MVP):** -- Stripe/payment integration (will add after learnings) -- Usage tracking analytics (basic only for MVP) -- Advanced rate limiting per plan (simple rate limit for MVP) -- Customer support portal (email support only for MVP) - -### Critical Discovery: 70% Already Built! - -**n8n-mcp analysis** revealed the codebase already has: -- โœ… `InstanceContext` pattern for per-user isolation -- โœ… LRU cache with TTL for API clients -- โœ… All 16 MCP tools context-aware -- โœ… HTTP header extraction for multi-tenant -- โœ… Session management with cleanup - -**Implementation reduced from 15-20 days to 5-7 days!** - -### Infrastructure Sizing (Telemetry-Based) - -**Current Usage (600 DAU distributed):** -- Peak RPS: 116 max, 44 p99, 21 p95, 6.6 avg -- Concurrent users: 8 max, 4 p95, 2 avg -- Peak hours: 13:00-16:00 UTC - -**MVP Launch Config (471 waitlist users):** -- 1x CPX31 (4 vCPU, 8GB RAM, 160GB) - โ‚ฌ14.00/mo -- PostgreSQL Basic (2 vCPU, 4GB, 80GB) - โ‚ฌ33.00/mo -- Load Balancer LB11 - โ‚ฌ5.49/mo -- Object Storage 100GB - โ‚ฌ2.00/mo -- **Total: โ‚ฌ54.49/month (~โ‚ฌ0.12/user)** - -**Scale trigger:** Add 2nd app server when DAU > 800 or RPS > 30 sustained - -### Timeline - -| Week | Phase | Deliverable | -|------|-------|-------------| -| **Week 1** | Infrastructure + Multi-tenant backend | Working MCP service with API key auth | -| **Week 2** | Dashboard (Next.js 15 + Supabase) | User can sign up, create keys, configure n8n | -| **Week 3** | Integration + Testing | All platforms tested, waitlist invited | -| **Week 4** | Launch + Monitoring | MVP live, gathering feedback | - -**Launch Date:** End of Week 4 (November 8, 2025 target) - ---- - -## Repository Structure - -### Separate Repositories (User Decision) - -``` -1. n8n-mcp (backend service) - โ”œโ”€โ”€ Multi-tenant API key authentication - โ”œโ”€โ”€ MCP server (HTTP Streamable only) - โ”œโ”€โ”€ Docker Compose deployment - โ””โ”€โ”€ Located: /Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp - -2. n8n-mcp-landing (frontend web app) - โ”œโ”€โ”€ Next.js 15 + Supabase + shadcn/ui - โ”œโ”€โ”€ User dashboard and authentication - โ”œโ”€โ”€ API key management UI - โ””โ”€โ”€ Located: /Users/romualdczlonkowski/Pliki/n8n-mcp-landing - โ””โ”€โ”€ Already using Next.js 15.3.4 โœ… -``` - -**Rationale:** Separate repos allow independent deployments and users configure MCP clients via URLs anyway. - ---- - -## Release Plan - -### MVP (Week 1-4): Waitlist Launch - -**Goal:** Get 471 waitlist users using hosted n8n-mcp service (free) - -#### Backend (n8n-mcp service) - -**What's Needed:** -1. **API Key Authentication** (2-3 days) - - PostgreSQL connection for user data - - API key validation middleware - - Load per-user n8n credentials - - **Discovery:** 70% already implemented via `InstanceContext` - -2. **HTTP Streamable Only** (1 day) - - Remove SSE transport code - - Simplify to StreamableHTTP only - - Update health checks - -3. **Docker Compose Stack** (2 days) - - Production docker-compose.yml (3 containers) - - Nginx load balancer - - Redis for sessions - - Prometheus + Grafana monitoring - - Zero-downtime deployment script - -4. **Database Schema** (1 day) - - Supabase PostgreSQL schema - - Tables: users, api_keys, n8n_instances, usage_logs - - RLS policies - - Indexes for performance - -**Total Backend:** 6-7 days - -#### Frontend (n8n-mcp-landing) - -**What's Needed:** -1. **Supabase Authentication** (2 days) - - Email/password signup (no OAuth for MVP) - - Email verification flow - - Protected routes middleware - - **Already Next.js 15 โœ…** - -2. **Dashboard Pages** (3-4 days) - - Landing page update (redirect users to hosted service) - - Dashboard overview - - API key management (create, view, revoke) - - n8n instance configuration form - - Account settings - - **Use existing shadcn/ui components โœ…** - -3. **Integration with Backend** (1 day) - - Supabase client setup - - RLS policies - - Type generation from database - -**Total Frontend:** 6-7 days - -#### Infrastructure & DevOps - -**What's Needed:** -1. **Hetzner Setup** (1 day) - - Provision CPX31 + PostgreSQL + LB - - DNS configuration (www + api subdomains) - - SSL certificates (Let's Encrypt) - -2. **CI/CD Pipeline** (1 day) - - GitHub Actions for backend - - Docker build + push to GHCR - - Automated deployment via SSH - - Rollback procedure - -**Total DevOps:** 2 days - -#### Testing & Launch - -**What's Needed:** -1. **Testing** (3 days) - - Unit tests (authentication, multi-tenant isolation) - - Integration tests (full user flow) - - Platform testing (Claude, Cursor, Windsurf) - - Load testing (simulate 471 users) - -2. **Documentation** (2 days) - - User onboarding guide - - Platform-specific setup guides - - Troubleshooting docs - - Admin playbook - -3. **Waitlist Invitation** (1 day) - - Email campaign to 471 users - - Onboarding support - - Feedback collection - -**Total Testing:** 6 days - -### Post-MVP Release 1 (Week 5-6): Usage Analytics - -**Goal:** Understand how users are using the service - -**Features:** -- Usage tracking dashboard (requests per hour/day) -- Tool usage analytics (which MCP tools most popular) -- User engagement metrics (DAU, WAU, retention) -- Error tracking (Sentry integration) - -**Estimate:** 1-2 weeks - -### Post-MVP Release 2 (Week 7-10): Paid Tiers - -**Goal:** Start generating revenue from power users - -**Features:** -- Stripe integration (Pro + Enterprise tiers) -- Plan limits enforcement (rate limiting per plan) -- Upgrade/downgrade flows -- Billing dashboard -- Customer portal - -**Estimate:** 3-4 weeks - -### Post-MVP Release 3 (Week 11-12): Advanced Features - -**Goal:** Differentiate from self-hosted - -**Features:** -- Shared workflow templates (community) -- Team collaboration (multiple users per account) -- API key rotation automation -- Advanced monitoring (custom alerts) -- Priority support ticketing - -**Estimate:** 2 weeks - ---- - -## MVP Technical Architecture - -### 1. Backend Architecture (n8n-mcp) - -#### Multi-Tenant Flow - -``` -User Request with Bearer Token - โ†“ -[Nginx Load Balancer] - โ†“ -[API Key Validation Middleware] - โ”œโ”€> Query PostgreSQL for api_key - โ”œโ”€> Load user's n8n credentials - โ””โ”€> Create InstanceContext - โ†“ -[MCP Tool Handler] (existing code!) - โ”œโ”€> getN8nApiClient(context) - โ””โ”€> Uses LRU cache (80%+ hit rate) - โ†“ -[User's n8n Instance] - โ†“ -[Response to User] -``` - -#### Docker Compose Stack - -```yaml -services: - nginx: - - Load balancing (least_conn) - - Rate limiting (global) - - Health checks - - WebSocket support - - mcp-app-1, mcp-app-2, mcp-app-3: - - n8n-mcp containers (HTTP Streamable only) - - Health checks every 30s - - Graceful shutdown (SIGTERM) - - Resource limits (2GB RAM, 1 CPU each) - - redis: - - Session storage - - Rate limit tracking - - Persistence (AOF) - - prometheus: - - Metrics collection - - 30-day retention - - grafana: - - Dashboards - - Alerting -``` - -#### Files to Modify - -**1. src/http-server-single-session.ts** (200 lines modified) -```typescript -// ADD: API key validation -async function validateApiKey(apiKey: string): Promise { - const { data, error } = await supabase - .from('api_keys') - .select('user_id, n8n_instances(instance_url, api_key_encrypted)') - .eq('key_hash', await bcrypt.hash(apiKey, 10)) - .eq('is_active', true) - .single(); - - if (error || !data) throw new UnauthorizedError(); - - // Decrypt n8n API key - const n8nApiKey = decrypt(data.n8n_instances.api_key_encrypted, data.user_id); - - return { - user_id: data.user_id, - n8n_url: data.n8n_instances.instance_url, - n8n_api_key: n8nApiKey - }; -} - -// MODIFY: Request handler -async handleRequest(req: Request): Promise { - const apiKey = req.headers.get('Authorization')?.replace('Bearer ', ''); - const userContext = await validateApiKey(apiKey); - - // Create InstanceContext (existing pattern!) - const context: InstanceContext = { - n8nApiUrl: userContext.n8n_url, - n8nApiKey: userContext.n8n_api_key - }; - - // Existing code handles the rest! - return this.mcpServer.handleRequest(req, context); -} -``` - -**2. src/services/api-key-validator.ts** (NEW - 400 lines) -- PostgreSQL connection pooling -- bcrypt validation -- n8n credential decryption (AES-256-GCM) -- Rate limit checking -- Audit logging - -**3. Remove SSE Transport** (1 day) -- Delete `src/http-server-single-session.ts` lines handling SSE -- Keep only StreamableHTTPServerTransport -- Update tests - -**4. Database Connection** -```typescript -// NEW: src/services/database.ts -import { createClient } from '@supabase/supabase-js'; - -export const supabase = createClient( - process.env.SUPABASE_URL!, - process.env.SUPABASE_SERVICE_KEY!, // Service role bypasses RLS - { - auth: { persistSession: false }, - db: { schema: 'public' } - } -); -``` - -#### Environment Variables - -```bash -# New for MVP -DATABASE_URL=postgresql://... -SUPABASE_URL=https://xxx.supabase.co -SUPABASE_SERVICE_KEY=eyJxxx... # Service role key -AUTH_MODE=api_key # New mode -ENABLE_MULTI_TENANT=true -MASTER_ENCRYPTION_KEY=xxx # For n8n credentials - -# Existing -NODE_ENV=production -MCP_MODE=http -PORT=3000 -NODES_DB_PATH=/app/data/nodes.db -``` - -### 2. Frontend Architecture (n8n-mcp-landing) - -#### Supabase Schema - -```sql --- Users table (extends auth.users) -CREATE TABLE public.users ( - id UUID PRIMARY KEY REFERENCES auth.users(id), - email TEXT NOT NULL UNIQUE, - full_name TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- API Keys table -CREATE TABLE public.api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - key_hash TEXT NOT NULL UNIQUE, - key_prefix TEXT NOT NULL, -- For display: "nmcp_abc123..." - name TEXT NOT NULL, - last_used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - is_active BOOLEAN DEFAULT TRUE -); - --- n8n Instance Configuration -CREATE TABLE public.n8n_instances ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - instance_url TEXT NOT NULL, - api_key_encrypted TEXT NOT NULL, -- Encrypted with per-user key - is_active BOOLEAN DEFAULT TRUE, - last_validated_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - CONSTRAINT unique_user_instance UNIQUE(user_id, instance_url) -); - --- Usage tracking (basic for MVP) -CREATE TABLE public.usage_logs ( - id BIGSERIAL PRIMARY KEY, - user_id UUID NOT NULL REFERENCES public.users(id), - api_key_id UUID REFERENCES public.api_keys(id), - tool_name TEXT NOT NULL, - status TEXT NOT NULL, -- 'success' | 'error' | 'rate_limited' - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- RLS Policies -ALTER TABLE public.users ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.api_keys ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.n8n_instances ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.usage_logs ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Users can view own data" ON public.users - FOR SELECT USING (auth.uid() = id); - -CREATE POLICY "Users can manage own API keys" ON public.api_keys - FOR ALL USING (auth.uid() = user_id); - -CREATE POLICY "Users can manage own n8n config" ON public.n8n_instances - FOR ALL USING (auth.uid() = user_id); - -CREATE POLICY "Users can view own usage" ON public.usage_logs - FOR SELECT USING (auth.uid() = user_id); -``` - -#### Next.js 15 App Structure - -``` -src/app/ -โ”œโ”€โ”€ (auth)/ -โ”‚ โ”œโ”€โ”€ login/page.tsx -โ”‚ โ”œโ”€โ”€ signup/page.tsx -โ”‚ โ””โ”€โ”€ verify-email/page.tsx -โ”œโ”€โ”€ (dashboard)/ -โ”‚ โ”œโ”€โ”€ dashboard/page.tsx # Overview -โ”‚ โ”œโ”€โ”€ api-keys/page.tsx # Create, view, revoke keys -โ”‚ โ”œโ”€โ”€ n8n-config/page.tsx # Configure n8n instance -โ”‚ โ””โ”€โ”€ settings/page.tsx # Account settings -โ”œโ”€โ”€ api/ -โ”‚ โ”œโ”€โ”€ auth/callback/route.ts # Supabase auth callback -โ”‚ โ””โ”€โ”€ webhooks/ -โ”‚ โ””โ”€โ”€ (future stripe webhook) -โ”œโ”€โ”€ layout.tsx -โ”œโ”€โ”€ page.tsx # Landing page (updated) -โ””โ”€โ”€ middleware.ts # Auth protection - -src/components/ -โ”œโ”€โ”€ api-key-card.tsx -โ”œโ”€โ”€ n8n-config-form.tsx -โ”œโ”€โ”€ usage-chart.tsx (basic for MVP) -โ””โ”€โ”€ ui/ (existing shadcn/ui) - -src/lib/ -โ”œโ”€โ”€ supabase/ -โ”‚ โ”œโ”€โ”€ client.ts # Browser client -โ”‚ โ”œโ”€โ”€ server.ts # Server client -โ”‚ โ””โ”€โ”€ middleware.ts # Auth middleware -โ””โ”€โ”€ utils.ts (existing) -``` - -#### Key Components - -**1. Authentication Setup** - -```typescript -// src/lib/supabase/middleware.ts -import { createServerClient } from '@supabase/ssr'; -import { NextResponse } from 'next/server'; - -export async function updateSession(request: NextRequest) { - let response = NextResponse.next(); - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get: (name) => request.cookies.get(name)?.value, - set: (name, value, options) => { - response.cookies.set({ name, value, ...options }); - }, - remove: (name, options) => { - response.cookies.set({ name, value: '', ...options }); - }, - }, - } - ); - - const { data: { user } } = await supabase.auth.getUser(); - - // Protected routes - if (!user && request.nextUrl.pathname.startsWith('/dashboard')) { - return NextResponse.redirect(new URL('/login', request.url)); - } - - return response; -} -``` - -**2. API Key Management** - -```typescript -// src/app/(dashboard)/api-keys/page.tsx -'use server'; - -import { createClient } from '@/lib/supabase/server'; -import crypto from 'crypto'; -import bcrypt from 'bcryptjs'; - -export async function generateApiKey(name: string) { - const supabase = createClient(); - const { data: { user } } = await supabase.auth.getUser(); - - // Generate secure key - const key = crypto.randomBytes(32).toString('base64url'); - const fullKey = `nmcp_${key}`; - const hash = await bcrypt.hash(fullKey, 10); - const prefix = `nmcp_${key.substring(0, 8)}...`; - - // Store in database - const { data, error } = await supabase - .from('api_keys') - .insert({ - user_id: user!.id, - key_hash: hash, - key_prefix: prefix, - name: name - }) - .select() - .single(); - - return { key: fullKey, id: data.id }; // Show only once! -} -``` - -**3. n8n Configuration Form** - -```typescript -// src/app/(dashboard)/n8n-config/page.tsx -'use server'; - -import { encrypt } from '@/lib/encryption'; - -export async function saveN8nConfig( - instanceUrl: string, - apiKey: string -) { - const supabase = createClient(); - const { data: { user } } = await supabase.auth.getUser(); - - // Test connection - const response = await fetch(`${instanceUrl}/api/v1/workflows`, { - headers: { 'X-N8N-API-KEY': apiKey } - }); - - if (!response.ok) { - throw new Error('Invalid n8n credentials'); - } - - // Encrypt and store - const encryptedKey = encrypt(apiKey, user!.id); - - await supabase.from('n8n_instances').upsert({ - user_id: user!.id, - instance_url: instanceUrl, - api_key_encrypted: encryptedKey, - last_validated_at: new Date().toISOString() - }); -} -``` - -### 3. Infrastructure Setup - -#### Hetzner Provisioning - -```bash -# Via Hetzner Cloud Console -1. Create project "n8n-mcp-production" -2. Create CPX31 server (โ‚ฌ14/mo) - - Location: Falkenstein, Germany - - Image: Ubuntu 22.04 LTS - - SSH keys: Add your public key -3. Create Managed PostgreSQL Basic (โ‚ฌ33/mo) - - Version: PostgreSQL 15 - - Backups: Enabled -4. Create Load Balancer LB11 (โ‚ฌ5.49/mo) - - Algorithm: Least connections - - Health checks: HTTP /health -5. Create Object Storage (โ‚ฌ2/mo) - - For backups and logs -``` - -#### DNS Configuration - -``` -A www.n8n-mcp.com โ†’ Load Balancer IP -A api.n8n-mcp.com โ†’ Load Balancer IP -TXT _acme-challenge โ†’ (for SSL verification) -``` - -#### Docker Compose Deployment - -```bash -# On server -cd /opt -git clone https://github.com/czlonkowski/n8n-mcp.git -cd n8n-mcp - -# Create secrets -mkdir -p secrets -echo "your-postgres-password" > secrets/postgres_password.txt -echo "your-master-encryption-key" > secrets/master_encryption_key.txt -chmod 600 secrets/*.txt - -# Create .env -cat > .env << EOF -DATABASE_URL=postgresql://user:pass@postgres-host:5432/n8n_mcp -SUPABASE_URL=https://xxx.supabase.co -SUPABASE_SERVICE_KEY=eyJxxx... -AUTH_MODE=api_key -ENABLE_MULTI_TENANT=true -NODE_ENV=production -EOF - -# Build and deploy -docker compose -f docker-compose.prod.yml up -d - -# Verify -curl http://localhost:3000/health -``` - -#### Zero-Downtime Deployment - -```bash -# Install docker-rollout plugin -curl -fsSL https://github.com/wowu/docker-rollout/releases/latest/download/docker-rollout \ - -o ~/.docker/cli-plugins/docker-rollout -chmod +x ~/.docker/cli-plugins/docker-rollout - -# Deploy script (6x per day) -#!/bin/bash -# deploy.sh - -set -e - -echo "Building new image..." -docker build -t ghcr.io/czlonkowski/n8n-mcp:latest . -docker push ghcr.io/czlonkowski/n8n-mcp:latest - -echo "Rolling update..." -docker rollout mcp-app-1 mcp-app-2 mcp-app-3 - -echo "Deployment complete!" -``` - ---- - -## MVP User Flow - -### 1. User Signs Up (n8n-mcp-landing) - -``` -1. Visit www.n8n-mcp.com -2. Click "Get Started" (from waitlist email) -3. Sign up with email/password -4. Verify email (Supabase Auth link) -5. Redirected to dashboard -``` - -### 2. User Configures n8n Instance - -``` -1. Navigate to "n8n Configuration" -2. Enter n8n instance URL (e.g., https://my-n8n.com) -3. Enter n8n API key -4. Click "Test Connection" - โ”œโ”€> Backend validates credentials - โ””โ”€> Shows โœ… or โŒ -5. Click "Save" - โ”œโ”€> Encrypt n8n API key - โ””โ”€> Store in PostgreSQL -``` - -### 3. User Creates API Key - -``` -1. Navigate to "API Keys" -2. Click "Create New Key" -3. Enter friendly name (e.g., "Claude Desktop") -4. Click "Generate" -5. Modal shows key ONCE: - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Your API Key (save this securely!) โ”‚ - โ”‚ nmcp_abc123def456ghi789jkl012mno345 โ”‚ - โ”‚ [Copy to Clipboard] โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -6. User copies key -7. Key hash stored in database -``` - -### 4. User Configures MCP Client - -#### Claude Desktop - -```json -// Settings > Connectors > Add Custom Connector -{ - "name": "n8n-mcp Hosted", - "url": "https://api.n8n-mcp.com/mcp", - "authentication": { - "type": "bearer", - "token": "nmcp_abc123def456ghi789jkl012mno345" - } -} -``` - -#### Cursor - -```json -// ~/.cursor/mcp.json -{ - "servers": { - "n8n-mcp": { - "url": "https://api.n8n-mcp.com/mcp", - "headers": { - "Authorization": "Bearer nmcp_abc123def456ghi789jkl012mno345" - } - } - } -} -``` - -#### Windsurf - -```json -// Settings > MCP Servers -{ - "serverUrl": "https://api.n8n-mcp.com/mcp", - "authToken": "nmcp_abc123def456ghi789jkl012mno345" -} -``` - -### 5. User Tests Connection - -``` -1. Open MCP client (Claude/Cursor/Windsurf) -2. Type: "list n8n nodes" -3. MCP request flow: - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Client sends Bearer token โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Nginx routes to n8n-mcp โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Validate API key (PostgreSQL) โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Load user's n8n credentials โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Create InstanceContext โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Execute MCP tool (existing!) โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Return node list from nodes.db โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -4. User sees list of 536 n8n nodes -5. Success! โœ… -``` - ---- - -## Landing Page Migration Strategy - -### Current State - -**www.n8n-mcp.com** (n8n-mcp-landing repo): -- Landing page with waitlist signup (471 users) -- Community videos -- Feature showcase -- GitHub link for installation - -### MVP Changes - -**Update Landing Page to Direct Users to Hosted Service:** - -```typescript -// src/app/page.tsx - Update hero section - -export default function HomePage() { - return ( - <> - -

n8n-mcp: AI-Powered n8n Workflows

-

Use Claude, Cursor, Windsurf with your n8n workflows

- - {/* OLD: GitHub installation instructions */} - {/* NEW: Sign up for hosted service */} - -
- - -
- -

- 471 users from our waitlist already have access! - No credit card required. -

-
- - - {/* Highlight hosted benefits */} - -

Instant Setup

-

No installation needed. Sign up and start using in 5 minutes.

-
- - -

Secure & Private

-

Your n8n credentials encrypted. Your workflows stay in your instance.

-
- - -

All MCP Clients

-

Works with Claude Desktop, Cursor, Windsurf, and more.

-
- - -

Community Support

-

Join 471 users already building AI workflows.

-
-
- - {/* Keep existing community videos */} - - - {/* Add: Self-hosting still available */} - -

Prefer Self-Hosting?

-

- n8n-mcp is open source. You can still install it locally. - - View on GitHub โ†’ - -

-
- - ); -} -``` - -### Migration Steps - -1. **Keep existing landing page** (don't break links) -2. **Add signup flow** (new routes: /signup, /login) -3. **Add dashboard** (new routes: /dashboard/*) -4. **Update hero CTA** from "Install" to "Sign Up" -5. **Keep GitHub link** in footer (for self-hosters) -6. **Add "How It Works"** section explaining hosted service - -### Content Updates - -**Before (self-hosted focus):** -> "Install n8n-mcp and connect your n8n instance to Claude Desktop." - -**After (hosted service focus):** -> "Connect your n8n instance to Claude, Cursor, Windsurf in 5 minutes. No installation needed." - -**Keep both options visible:** -- Primary CTA: "Start Using Now" โ†’ /signup -- Secondary: "Self-Host" โ†’ GitHub - ---- - -## MVP Success Metrics - -### Week 1-2: Alpha Testing (Internal) - -| Metric | Target | -|--------|--------| -| Backend deployed | โœ… | -| Frontend deployed | โœ… | -| Internal testing complete | 10 test users | -| All platforms tested | Claude, Cursor, Windsurf | -| Zero critical bugs | 0 P0 issues | - -### Week 3-4: Beta Launch (Waitlist) - -| Metric | Target | Measurement | -|--------|--------|-------------| -| **Signups** | 300/471 (64%) | First 2 weeks | -| **Activation** | 70% | Users who configure n8n + create key | -| **First MCP Call** | 60% | Users who make โ‰ฅ1 MCP request | -| **Day 7 Retention** | 40% | Active 7 days after signup | -| **Platform Distribution** | - | % Claude vs Cursor vs Windsurf | - -### Operational Metrics (Ongoing) - -| Metric | Target | Alert Threshold | -|--------|--------|-----------------| -| **Uptime** | 99%+ | < 99% in 24h | -| **Response Time (p95)** | <500ms | >800ms for 5min | -| **Error Rate** | <1% | >2% for 5min | -| **Database Queries** | <50ms p95 | >100ms for 5min | -| **API Key Validation** | <20ms | >50ms | - -### User Feedback Collection - -**Methods:** -1. In-app feedback form (dashboard) -2. Email survey after 7 days -3. Weekly office hours (optional) -4. Discord/Slack community (existing?) - -**Key Questions:** -- How easy was setup? (1-5 scale) -- Which MCP client do you use? -- What workflows are you building? -- Would you pay for this? How much? -- What features do you need? - ---- - -## Post-MVP: Learnings โ†’ Paid Tiers - -### Hypothesis to Test - -**Assumption:** Users will pay for higher rate limits and priority support. - -**Data to Collect:** -- Average requests per user per day -- Peak usage times -- Most-used MCP tools -- Churn reasons (if users stop) -- Feature requests frequency - -### Pricing Strategy (Post-MVP) - -Based on learnings, implement: - -``` -Free Tier (Waitlist - MVP) -โ”œโ”€ 600 requests/hour (10/min) -โ”œโ”€ 10k requests/day -โ”œโ”€ 2 API keys -โ””โ”€ Community support - -Pro Tier (~โ‚ฌ10/month) -โ”œโ”€ 6,000 requests/hour (100/min) -โ”œโ”€ 100k requests/day -โ”œโ”€ 10 API keys -โ”œโ”€ Email support (24h response) -โ””โ”€ Workflow sharing (future) - -Enterprise Tier (Custom) -โ”œโ”€ Unlimited requests -โ”œโ”€ Unlimited API keys -โ”œโ”€ Dedicated support -โ”œโ”€ SLA guarantee -โ””โ”€ Custom integrations -``` - -### When to Add Payments - -**Criteria:** -1. โœ… 200+ active users (DAU) -2. โœ… 80%+ satisfaction score -3. โœ… <5% churn rate -4. โœ… Clear value proposition validated -5. โœ… User requests for paid features - -**Timeline:** 4-6 weeks after MVP launch - ---- - -## Risk Assessment - -### Technical Risks - -**RISK-01: Multi-tenant isolation failure** -- **Impact:** User A accesses User B's data -- **Likelihood:** Low (RLS policies + validation) -- **Mitigation:** - - Comprehensive testing with 2+ test users - - Audit logs for all API key validations - - Automated tests for RLS policies - -**RISK-02: n8n credential leakage** -- **Impact:** User's n8n instance compromised -- **Likelihood:** Low (AES-256-GCM encryption) -- **Mitigation:** - - Encryption tested thoroughly - - Master key rotation procedure documented - - Monitor for unusual n8n API calls - -**RISK-03: Database bottleneck** -- **Impact:** Slow response times, user frustration -- **Likelihood:** Medium (471 users hitting simultaneously) -- **Mitigation:** - - Connection pooling (Supavisor) - - Composite indexes on (user_id, created_at) - - Cache API key lookups (Redis) - -**RISK-04: Docker Compose limitations** -- **Impact:** Can't scale beyond single host -- **Likelihood:** Low (need >5k DAU to hit limits) -- **Mitigation:** - - Document Kubernetes migration path - - Re-evaluate at 2k DAU - -### Business Risks - -**RISK-05: Low waitlist conversion** -- **Impact:** <200/471 users sign up -- **Likelihood:** Medium (email list may be stale) -- **Mitigation:** - - Send personalized invitations - - Offer early bird benefits - - Follow up with non-responders - -**RISK-06: High churn** -- **Impact:** Users sign up but don't return -- **Likelihood:** Medium (setup friction, not enough value) -- **Mitigation:** - - Optimize onboarding flow (measure drop-offs) - - Email engagement campaigns - - User interviews to understand blockers - -**RISK-07: Insufficient value for paid tier** -- **Impact:** No one converts to paid (post-MVP) -- **Likelihood:** Medium (unknown willingness to pay) -- **Mitigation:** - - Collect payment intent data during MVP - - Survey users on pricing - - Offer early bird discounts to validate pricing - -### Operational Risks - -**RISK-08: Overwhelmed by support** -- **Impact:** Can't keep up with 471 users' questions -- **Likelihood:** High (new users will have issues) -- **Mitigation:** - - Comprehensive documentation - - FAQ page - - Community Discord/Slack - - Automated onboarding emails - -**RISK-09: Infrastructure costs exceed budget** -- **Impact:** โ‚ฌ54.49/mo not enough at scale -- **Likelihood:** Low (telemetry shows headroom) -- **Mitigation:** - - Monitor resource usage daily - - Scale trigger: Add server when CPU >60% - - Break-even: Only need 3.5 paying users post-MVP - ---- - -## Timeline & Milestones - -### Week 1: Backend Multi-Tenant + Infrastructure - -**Days 1-2: Infrastructure Setup** -- [ ] Provision Hetzner CPX31 + PostgreSQL + Load Balancer -- [ ] Configure DNS (www + api subdomains) -- [ ] Set up SSL certificates (Let's Encrypt) -- [ ] Deploy monitoring (Prometheus + Grafana) - -**Days 3-5: Multi-Tenant Backend** -- [ ] Implement API key validation (src/services/api-key-validator.ts) -- [ ] Modify HTTP server for multi-tenant (src/http-server-single-session.ts) -- [ ] Remove SSE transport code -- [ ] Add PostgreSQL connection (src/services/database.ts) -- [ ] Implement n8n credential decryption - -**Days 6-7: Testing & Docker** -- [ ] Unit tests (authentication, validation) -- [ ] Integration tests (multi-user scenarios) -- [ ] Create docker-compose.prod.yml -- [ ] Test zero-downtime deployment - -**Deliverable:** Working n8n-mcp service with API key authentication - -### Week 2: Frontend Dashboard - -**Days 1-2: Authentication** -- [ ] Set up Supabase project -- [ ] Implement email/password signup -- [ ] Email verification flow -- [ ] Protected routes middleware -- [ ] Login/logout flows - -**Days 3-4: Dashboard Pages** -- [ ] Dashboard overview (basic stats) -- [ ] API key management page - - Create new key - - View existing keys - - Revoke keys -- [ ] n8n configuration page - - Form for URL + API key - - Test connection button - - Save (encrypted) - -**Days 5-6: Polish & Integration** -- [ ] Account settings page -- [ ] Error handling and loading states -- [ ] Toast notifications (Sonner) -- [ ] Type generation from Supabase schema -- [ ] RLS policy testing - -**Day 7: Deployment** -- [ ] Deploy frontend to Vercel or Hetzner -- [ ] Test full user flow (signup โ†’ API key โ†’ MCP call) -- [ ] Fix critical bugs - -**Deliverable:** Functional dashboard where users can sign up and configure n8n-mcp - -### Week 3: Integration Testing & Documentation - -**Days 1-2: Platform Testing** -- [ ] Test Claude Desktop integration (Windows, Mac, Linux) -- [ ] Test Cursor integration -- [ ] Test Windsurf integration -- [ ] Test custom HTTP client (curl) -- [ ] Verify all 16 MCP tools work - -**Days 3-4: Load Testing** -- [ ] Simulate 471 users -- [ ] Test peak load (116 RPS from telemetry) -- [ ] Verify rate limiting works -- [ ] Database query performance testing -- [ ] Fix performance bottlenecks - -**Days 5-7: Documentation** -- [ ] User onboarding guide - - How to sign up - - How to configure n8n - - How to create API keys -- [ ] Platform-specific setup guides - - Claude Desktop step-by-step - - Cursor step-by-step - - Windsurf step-by-step -- [ ] Troubleshooting docs - - Common errors - - Debug steps -- [ ] Admin playbook - - Deployment procedures - - Rollback procedures - - Incident response - -**Deliverable:** Fully tested system with comprehensive documentation - -### Week 4: Launch to Waitlist - -**Days 1-2: Pre-Launch Prep** -- [ ] Final security audit -- [ ] Backup procedures tested -- [ ] Monitoring alerts configured (Slack) -- [ ] Status page set up (optional) -- [ ] Landing page updated (hosted service focus) - -**Day 3: Soft Launch (50 users)** -- [ ] Email first 50 users from waitlist -- [ ] Monitor closely for issues -- [ ] Gather immediate feedback -- [ ] Fix critical bugs - -**Days 4-5: Full Launch (471 users)** -- [ ] Email remaining 421 users -- [ ] Monitor onboarding funnel -- [ ] Respond to support questions -- [ ] Track activation rate - -**Days 6-7: Post-Launch** -- [ ] Analyze metrics (signups, activation, retention) -- [ ] User interviews (5-10 users) -- [ ] Identify top pain points -- [ ] Plan Release 1 (analytics) - -**Deliverable:** MVP live with 471 waitlist users invited - ---- - -## Implementation Checklist - -### Pre-Development - -- [ ] Budget approved (โ‚ฌ54.49/month for 4+ months) -- [ ] Team assignments clear (backend, frontend, devops) -- [ ] Accounts created: - - [ ] Hetzner Cloud - - [ ] Supabase - - [ ] GitHub Container Registry (GHCR) - - [ ] (Future) Stripe -- [ ] Development environment set up locally -- [ ] Access to n8n-mcp and n8n-mcp-landing repos - -### Week 1 Checklist - -**Infrastructure:** -- [ ] Hetzner CPX31 provisioned -- [ ] PostgreSQL Basic provisioned -- [ ] Load Balancer LB11 provisioned -- [ ] Object Storage provisioned -- [ ] DNS records created (www, api) -- [ ] SSL certificates obtained (Let's Encrypt) - -**Backend:** -- [ ] `src/services/api-key-validator.ts` implemented -- [ ] `src/http-server-single-session.ts` modified for multi-tenant -- [ ] SSE transport code removed -- [ ] `src/services/database.ts` created -- [ ] n8n credential encryption implemented -- [ ] Unit tests written (80%+ coverage) -- [ ] Integration tests written -- [ ] docker-compose.prod.yml created -- [ ] Zero-downtime deployment script tested - -**Verification:** -- [ ] Can authenticate with API key -- [ ] Multi-user isolation works (test with 2+ users) -- [ ] n8n credentials loaded correctly per user -- [ ] MCP tools work with InstanceContext -- [ ] Health checks pass -- [ ] Docker Compose deploys successfully - -### Week 2 Checklist - -**Supabase:** -- [ ] Supabase project created -- [ ] Database schema deployed (users, api_keys, n8n_instances, usage_logs) -- [ ] RLS policies enabled and tested -- [ ] Indexes created -- [ ] Email auth configured (SMTP) -- [ ] Email templates customized - -**Frontend:** -- [ ] Authentication flow implemented (signup, login, logout) -- [ ] Email verification tested -- [ ] Protected routes middleware works -- [ ] Dashboard overview page -- [ ] API key management page (create, view, revoke) -- [ ] n8n configuration page (form, test, save) -- [ ] Account settings page -- [ ] Error handling and loading states -- [ ] Toast notifications working -- [ ] TypeScript types generated from Supabase - -**Verification:** -- [ ] User can sign up and verify email -- [ ] User can create API key and see it once -- [ ] User can configure n8n instance (encrypted) -- [ ] User can revoke API key -- [ ] RLS policies prevent cross-user data access -- [ ] Frontend deployed (Vercel or Hetzner) - -### Week 3 Checklist - -**Testing:** -- [ ] Claude Desktop tested (Mac, Windows) -- [ ] Cursor tested -- [ ] Windsurf tested -- [ ] All 16 MCP tools tested -- [ ] Load test (471 users simulated) -- [ ] Database performance verified (<50ms p95) -- [ ] Rate limiting tested -- [ ] Error scenarios tested (invalid API key, invalid n8n creds, etc.) - -**Documentation:** -- [ ] User onboarding guide written -- [ ] Platform setup guides written (Claude, Cursor, Windsurf) -- [ ] Troubleshooting docs written -- [ ] Admin playbook written -- [ ] API reference updated -- [ ] Landing page updated with hosted service info - -**Verification:** -- [ ] End-to-end user flow works flawlessly -- [ ] Documentation is clear and comprehensive -- [ ] No critical bugs remaining -- [ ] Performance meets targets - -### Week 4 Checklist - -**Pre-Launch:** -- [ ] Security audit completed -- [ ] Backup procedures documented and tested -- [ ] Monitoring alerts configured -- [ ] Email templates for waitlist invitation -- [ ] Landing page updated (CTA to signup) -- [ ] Support email set up - -**Launch:** -- [ ] Soft launch email sent (50 users) -- [ ] Monitoring onboarding metrics -- [ ] Support questions answered -- [ ] Critical bugs fixed -- [ ] Full launch email sent (421 users) - -**Post-Launch:** -- [ ] Metrics analyzed (signups, activation, retention) -- [ ] User feedback collected (survey, interviews) -- [ ] Pain points identified -- [ ] Release 1 planned (analytics) - ---- - -## Cost Summary - -### MVP Development Costs - -**Infrastructure (Monthly):** -- CPX31 (4 vCPU, 8GB): โ‚ฌ14.00 -- PostgreSQL Basic: โ‚ฌ33.00 -- Load Balancer LB11: โ‚ฌ5.49 -- Object Storage 100GB: โ‚ฌ2.00 -- **Total: โ‚ฌ54.49/month** - -**Cost per user:** โ‚ฌ54.49 / 471 = **โ‚ฌ0.12/user/month** - -**4-month MVP period:** โ‚ฌ54.49 ร— 4 = **โ‚ฌ217.96** - -**Development Time:** -- Backend: 7 days -- Frontend: 7 days -- Testing: 7 days -- Launch: 7 days -- **Total: 28 days (4 weeks)** - -### Break-Even Analysis (Post-MVP) - -**With Paid Tiers (Post-MVP Release 2):** - -Assumptions: -- 10% convert to Pro (โ‚ฌ10/month) = 47 users = โ‚ฌ470/month -- 2% convert to Enterprise (โ‚ฌ100/month avg) = 9 users = โ‚ฌ900/month -- **Total revenue: โ‚ฌ1,370/month** - -Costs: -- Infrastructure: โ‚ฌ54.49 -- Stripe fees (3%): โ‚ฌ41.10 -- **Net profit: โ‚ฌ1,274.51/month** - -Break-even: 3.5 paying users = **Achieved at 1% conversion** โœ… - ---- - -## Next Steps - -### Immediate Actions (Today) - -1. **Review this MVP plan** - Confirm scope and timeline -2. **Assign team roles** - Backend, frontend, devops -3. **Create Hetzner account** - If not already done -4. **Create Supabase project** - Free tier for development -5. **Set up local development**: - - Backend: n8n-mcp repo - - Frontend: n8n-mcp-landing repo - -### Week 1 Kick-off (Monday) - -1. **Infrastructure setup** (Day 1) - - Provision Hetzner resources - - Configure DNS - - Set up monitoring - -2. **Start backend development** (Day 2) - - Create branch: `feature/multi-tenant` - - Begin API key validation implementation - - Set up PostgreSQL connection - -3. **Start frontend development** (Day 2) - - Create branch: `feature/dashboard` - - Set up Supabase authentication - - Begin dashboard layout - -### Questions to Answer - -Before starting development: - -1. **Team** - - Who is responsible for backend? - - Who is responsible for frontend? - - Who is responsible for devops? - - Do we need to hire contractors? - -2. **Budget** - - โ‚ฌ54.49/month infrastructure approved? - - Budget for 4+ months until revenue? - -3. **Timeline** - - 4-week MVP realistic? - - Any external dependencies? - - Hard deadlines? - -4. **Scope** - - MVP features confirmed? - - Any must-haves missing? - - Any nice-to-haves to remove? - ---- - -## Conclusion - -**MVP is achievable in 4 weeks** thanks to: -1. โœ… 70% multi-tenant code already exists (InstanceContext) -2. โœ… Landing page already on Next.js 15 -3. โœ… Infrastructure sizing validated by telemetry (600 DAU baseline) -4. โœ… All technologies researched with production patterns - -**Key Success Factors:** -- Focus ruthlessly on MVP scope (no scope creep!) -- Leverage existing code (InstanceContext pattern) -- Use proven patterns (Supabase + Next.js 15) -- Test with real users early (50-user soft launch) -- Gather feedback relentlessly - -**After MVP:** -- Release 1: Usage analytics (1-2 weeks) -- Release 2: Paid tiers with Stripe (3-4 weeks) -- Release 3: Advanced features (2 weeks) - -**Go/No-Go Decision:** - -โœ… **Proceed if:** -- Team capacity available (3-4 weeks full-time or 6-8 weeks part-time) -- Budget approved (โ‚ฌ217.96 for 4 months) -- Commitment to post-launch support (monitoring, user support) - -โŒ **Delay if:** -- Team at capacity with other projects -- Uncertainty about maintaining hosted service long-term -- Budget constraints - ---- - -**Document Version:** 3.0 - MVP Focus -**Last Updated:** 2025-10-11 -**Next Review:** After Week 1 completion -**Owner:** n8n-mcp Team diff --git a/TELEMETRY_PRUNING_GUIDE.md b/TELEMETRY_PRUNING_GUIDE.md deleted file mode 100644 index 61a6eed..0000000 --- a/TELEMETRY_PRUNING_GUIDE.md +++ /dev/null @@ -1,623 +0,0 @@ -# Telemetry Data Pruning & Aggregation Guide - -## Overview - -This guide provides a complete solution for managing n8n-mcp telemetry data in Supabase to stay within the 500 MB free tier limit while preserving valuable insights for product development. - -## Current Situation - -- **Database Size**: 265 MB / 500 MB (53% of limit) -- **Growth Rate**: 7.7 MB/day (54 MB/week) -- **Time Until Full**: ~17 days -- **Total Events**: 641,487 events + 17,247 workflows - -### Storage Breakdown - -| Event Type | Count | Size | % of Total | -|------------|-------|------|------------| -| `tool_sequence` | 362,704 | 96 MB | 72% | -| `tool_used` | 191,938 | 28 MB | 21% | -| `validation_details` | 36,280 | 14 MB | 11% | -| `workflow_created` | 23,213 | 4.5 MB | 3% | -| Others | ~26,000 | ~3 MB | 2% | - -## Solution Strategy - -**Aggregate โ†’ Delete โ†’ Retain only recent raw events** - -### Expected Results - -| Metric | Before | After | Improvement | -|--------|--------|-------|-------------| -| Database Size | 265 MB | ~90-120 MB | **55-65% reduction** | -| Growth Rate | 7.7 MB/day | ~2-3 MB/day | **60-70% slower** | -| Days Until Full | 17 days | **Sustainable** | Never fills | -| Free Tier Usage | 53% | ~20-25% | **75-80% headroom** | - -## Implementation Steps - -### Step 1: Execute the SQL Migration - -Open Supabase SQL Editor and run the entire contents of `supabase-telemetry-aggregation.sql`: - -```sql --- Copy and paste the entire supabase-telemetry-aggregation.sql file --- Or run it directly from the file -``` - -This will create: -- 5 aggregation tables -- Aggregation functions -- Automated cleanup function -- Monitoring functions -- Scheduled cron job (daily at 2 AM UTC) - -### Step 2: Verify Cron Job Setup - -Check that the cron job was created successfully: - -```sql --- View scheduled cron jobs -SELECT - jobid, - schedule, - command, - nodename, - nodeport, - database, - username, - active -FROM cron.job -WHERE jobname = 'telemetry-daily-cleanup'; -``` - -Expected output: -- Schedule: `0 2 * * *` (daily at 2 AM UTC) -- Active: `true` - -### Step 3: Run Initial Emergency Cleanup - -Get immediate space relief by running the emergency cleanup: - -```sql --- This will aggregate and delete data older than 7 days -SELECT * FROM emergency_cleanup(); -``` - -Expected results: -``` -action | rows_deleted | space_freed_mb -------------------------------------+--------------+---------------- -Deleted non-critical events > 7d | ~284,924 | ~52 MB -Deleted error events > 14d | ~2,400 | ~0.5 MB -Deleted duplicate workflows | ~8,500 | ~11 MB -TOTAL (run VACUUM separately) | 0 | ~63.5 MB -``` - -### Step 4: Reclaim Disk Space - -After deletion, reclaim the actual disk space: - -```sql --- Reclaim space from deleted rows -VACUUM FULL telemetry_events; -VACUUM FULL telemetry_workflows; - --- Update statistics for query optimization -ANALYZE telemetry_events; -ANALYZE telemetry_workflows; -``` - -**Note**: `VACUUM FULL` may take a few minutes and locks the table. Run during off-peak hours if possible. - -### Step 5: Verify Results - -Check the new database size: - -```sql -SELECT * FROM check_database_size(); -``` - -Expected output: -``` -total_size_mb | events_size_mb | workflows_size_mb | aggregates_size_mb | percent_of_limit | days_until_full | status ---------------+----------------+-------------------+--------------------+------------------+-----------------+--------- -202.5 | 85.2 | 35.8 | 12.5 | 40.5 | ~95 | HEALTHY -``` - -## Daily Operations (Automated) - -Once set up, the system runs automatically: - -1. **Daily at 2 AM UTC**: Cron job runs -2. **Aggregation**: Data older than 3 days is aggregated into summary tables -3. **Deletion**: Raw events are deleted after aggregation -4. **Cleanup**: VACUUM runs to reclaim space -5. **Retention**: - - High-volume events: 3 days - - Error events: 30 days - - Aggregated insights: Forever - -## Monitoring Commands - -### Check Database Health - -```sql --- View current size and status -SELECT * FROM check_database_size(); -``` - -### View Aggregated Insights - -```sql --- Top tools used daily -SELECT - aggregation_date, - tool_name, - usage_count, - success_count, - error_count, - ROUND(100.0 * success_count / NULLIF(usage_count, 0), 1) as success_rate_pct -FROM telemetry_tool_usage_daily -ORDER BY aggregation_date DESC, usage_count DESC -LIMIT 50; - --- Most common tool sequences -SELECT - aggregation_date, - tool_sequence, - occurrence_count, - ROUND(avg_sequence_duration_ms, 0) as avg_duration_ms, - ROUND(100 * success_rate, 1) as success_rate_pct -FROM telemetry_tool_patterns -ORDER BY occurrence_count DESC -LIMIT 20; - --- Error patterns over time -SELECT - aggregation_date, - error_type, - error_context, - occurrence_count, - affected_users, - sample_error_message -FROM telemetry_error_patterns -ORDER BY aggregation_date DESC, occurrence_count DESC -LIMIT 30; - --- Workflow creation trends -SELECT - aggregation_date, - complexity, - node_count_range, - has_trigger, - has_webhook, - workflow_count, - ROUND(avg_node_count, 1) as avg_nodes -FROM telemetry_workflow_insights -ORDER BY aggregation_date DESC, workflow_count DESC -LIMIT 30; - --- Validation success rates -SELECT - aggregation_date, - validation_type, - profile, - success_count, - failure_count, - ROUND(100.0 * success_count / NULLIF(success_count + failure_count, 0), 1) as success_rate_pct, - common_failure_reasons -FROM telemetry_validation_insights -ORDER BY aggregation_date DESC, (success_count + failure_count) DESC -LIMIT 30; -``` - -### Check Cron Job Execution History - -```sql --- View recent cron job runs -SELECT - runid, - jobid, - database, - status, - return_message, - start_time, - end_time -FROM cron.job_run_details -WHERE jobid = (SELECT jobid FROM cron.job WHERE jobname = 'telemetry-daily-cleanup') -ORDER BY start_time DESC -LIMIT 10; -``` - -## Manual Operations - -### Run Cleanup On-Demand - -If you need to run cleanup outside the scheduled time: - -```sql --- Run with default 3-day retention -SELECT * FROM run_telemetry_aggregation_and_cleanup(3); -VACUUM ANALYZE telemetry_events; - --- Or with custom retention (e.g., 5 days) -SELECT * FROM run_telemetry_aggregation_and_cleanup(5); -VACUUM ANALYZE telemetry_events; -``` - -### Emergency Cleanup (Critical Situations) - -If database is approaching limit and you need immediate relief: - -```sql --- Step 1: Run emergency cleanup (7-day retention) -SELECT * FROM emergency_cleanup(); - --- Step 2: Reclaim space aggressively -VACUUM FULL telemetry_events; -VACUUM FULL telemetry_workflows; -ANALYZE telemetry_events; -ANALYZE telemetry_workflows; - --- Step 3: Verify results -SELECT * FROM check_database_size(); -``` - -### Adjust Retention Policy - -To change the default 3-day retention period: - -```sql --- Update cron job to use 5-day retention instead -SELECT cron.unschedule('telemetry-daily-cleanup'); - -SELECT cron.schedule( - 'telemetry-daily-cleanup', - '0 2 * * *', -- Daily at 2 AM UTC - $$ - SELECT run_telemetry_aggregation_and_cleanup(5); -- 5 days instead of 3 - VACUUM ANALYZE telemetry_events; - VACUUM ANALYZE telemetry_workflows; - $$ -); -``` - -## Data Retention Policies - -### Raw Events Retention - -| Event Type | Retention | Reason | -|------------|-----------|--------| -| `tool_sequence` | 3 days | High volume, low long-term value | -| `tool_used` | 3 days | High volume, aggregated daily | -| `validation_details` | 3 days | Aggregated into insights | -| `workflow_created` | 3 days | Aggregated into patterns | -| `session_start` | 3 days | Operational data only | -| `search_query` | 3 days | Operational data only | -| `error_occurred` | **30 days** | Extended for debugging | -| `workflow_validation_failed` | 3 days | Captured in aggregates | - -### Aggregated Data Retention - -All aggregated data is kept **indefinitely**: -- Daily tool usage statistics -- Tool sequence patterns -- Workflow creation trends -- Error patterns and frequencies -- Validation success rates - -### Workflow Retention - -- **Unique workflows**: Kept indefinitely (one per unique hash) -- **Duplicate workflows**: Deleted after 3 days -- **Workflow metadata**: Aggregated into daily insights - -## Intelligence Preserved - -Even after aggressive pruning, you still have access to: - -### Long-term Product Insights -- Which tools are most/least used over time -- Tool usage trends and adoption curves -- Common workflow patterns and complexities -- Error frequencies and types across versions -- Validation failure patterns - -### Development Intelligence -- Feature adoption rates (by day/week/month) -- Pain points (high error rates, validation failures) -- User behavior patterns (tool sequences, workflow styles) -- Version comparison (changes in usage between releases) - -### Recent Debugging Data -- Last 3 days of raw events for immediate issues -- Last 30 days of error events for bug tracking -- Sample error messages for each error type - -## Troubleshooting - -### Cron Job Not Running - -Check if pg_cron extension is enabled: - -```sql --- Enable pg_cron -CREATE EXTENSION IF NOT EXISTS pg_cron; - --- Verify it's enabled -SELECT * FROM pg_extension WHERE extname = 'pg_cron'; -``` - -### Aggregation Functions Failing - -Check for errors in cron job execution: - -```sql --- View error messages -SELECT - status, - return_message, - start_time -FROM cron.job_run_details -WHERE jobid = (SELECT jobid FROM cron.job WHERE jobname = 'telemetry-daily-cleanup') - AND status = 'failed' -ORDER BY start_time DESC; -``` - -### VACUUM Not Reclaiming Space - -If `VACUUM ANALYZE` isn't reclaiming enough space, use `VACUUM FULL`: - -```sql --- More aggressive space reclamation (locks table) -VACUUM FULL telemetry_events; -``` - -### Database Still Growing Too Fast - -Reduce retention period further: - -```sql --- Change to 2-day retention (more aggressive) -SELECT * FROM run_telemetry_aggregation_and_cleanup(2); -``` - -Or delete more event types: - -```sql --- Delete additional low-value events -DELETE FROM telemetry_events -WHERE created_at < NOW() - INTERVAL '3 days' - AND event IN ('session_start', 'search_query', 'diagnostic_completed', 'health_check_completed'); -``` - -## Performance Considerations - -### Cron Job Execution Time - -The daily cleanup typically takes: -- **Aggregation**: 30-60 seconds -- **Deletion**: 15-30 seconds -- **VACUUM**: 2-5 minutes -- **Total**: ~3-7 minutes - -### Query Performance - -All aggregation tables have indexes on: -- Date columns (for time-series queries) -- Lookup columns (tool_name, error_type, etc.) -- User columns (for user-specific analysis) - -### Lock Considerations - -- `VACUUM ANALYZE`: Minimal locking, safe during operation -- `VACUUM FULL`: Locks table, run during off-peak hours -- Aggregation functions: Read-only queries, no locking - -## Customization - -### Add Custom Aggregations - -To track additional metrics, create new aggregation tables: - -```sql --- Example: Session duration aggregation -CREATE TABLE telemetry_session_duration_daily ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - aggregation_date DATE NOT NULL, - avg_duration_seconds NUMERIC, - median_duration_seconds NUMERIC, - max_duration_seconds NUMERIC, - session_count INTEGER, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(aggregation_date) -); - --- Add to cleanup function --- (modify run_telemetry_aggregation_and_cleanup) -``` - -### Modify Retention Policies - -Edit the `run_telemetry_aggregation_and_cleanup` function to adjust retention by event type: - -```sql --- Keep validation_details for 7 days instead of 3 -DELETE FROM telemetry_events -WHERE created_at < (NOW() - INTERVAL '7 days') - AND event = 'validation_details'; -``` - -### Change Cron Schedule - -Adjust the execution time if needed: - -```sql --- Run at different time (e.g., 3 AM UTC) -SELECT cron.schedule( - 'telemetry-daily-cleanup', - '0 3 * * *', -- 3 AM instead of 2 AM - $$ SELECT run_telemetry_aggregation_and_cleanup(3); VACUUM ANALYZE telemetry_events; $$ -); - --- Run twice daily (2 AM and 2 PM) -SELECT cron.schedule( - 'telemetry-cleanup-morning', - '0 2 * * *', - $$ SELECT run_telemetry_aggregation_and_cleanup(3); $$ -); - -SELECT cron.schedule( - 'telemetry-cleanup-afternoon', - '0 14 * * *', - $$ SELECT run_telemetry_aggregation_and_cleanup(3); $$ -); -``` - -## Backup & Recovery - -### Before Running Emergency Cleanup - -Create a backup of aggregation queries: - -```sql --- Export aggregated data to CSV or backup tables -CREATE TABLE telemetry_tool_usage_backup AS -SELECT * FROM telemetry_tool_usage_daily; - -CREATE TABLE telemetry_patterns_backup AS -SELECT * FROM telemetry_tool_patterns; -``` - -### Restore Deleted Data - -Raw event data cannot be restored after deletion. However, aggregated insights are preserved indefinitely. - -To prevent accidental data loss: -1. Test cleanup functions on staging first -2. Review `check_database_size()` before running emergency cleanup -3. Start with longer retention periods (7 days) and reduce gradually -4. Monitor aggregated data quality for 1-2 weeks - -## Monitoring Dashboard Queries - -### Weekly Growth Report - -```sql --- Database growth over last 7 days -SELECT - DATE(created_at) as date, - COUNT(*) as events_created, - COUNT(DISTINCT event) as event_types, - COUNT(DISTINCT user_id) as active_users, - ROUND(SUM(pg_column_size(telemetry_events.*))::NUMERIC / 1024 / 1024, 2) as size_mb -FROM telemetry_events -WHERE created_at >= NOW() - INTERVAL '7 days' -GROUP BY DATE(created_at) -ORDER BY date DESC; -``` - -### Storage Efficiency Report - -```sql --- Compare raw vs aggregated storage -SELECT - 'Raw Events (last 3 days)' as category, - COUNT(*) as row_count, - pg_size_pretty(pg_total_relation_size('telemetry_events')) as table_size -FROM telemetry_events -WHERE created_at >= NOW() - INTERVAL '3 days' - -UNION ALL - -SELECT - 'Aggregated Insights (all time)', - (SELECT COUNT(*) FROM telemetry_tool_usage_daily) + - (SELECT COUNT(*) FROM telemetry_tool_patterns) + - (SELECT COUNT(*) FROM telemetry_workflow_insights) + - (SELECT COUNT(*) FROM telemetry_error_patterns) + - (SELECT COUNT(*) FROM telemetry_validation_insights), - pg_size_pretty( - pg_total_relation_size('telemetry_tool_usage_daily') + - pg_total_relation_size('telemetry_tool_patterns') + - pg_total_relation_size('telemetry_workflow_insights') + - pg_total_relation_size('telemetry_error_patterns') + - pg_total_relation_size('telemetry_validation_insights') - ); -``` - -### Top Events by Size - -```sql --- Which event types consume most space -SELECT - event, - COUNT(*) as event_count, - pg_size_pretty(SUM(pg_column_size(telemetry_events.*))::BIGINT) as total_size, - pg_size_pretty(AVG(pg_column_size(telemetry_events.*))::BIGINT) as avg_size_per_event, - ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) as pct_of_events -FROM telemetry_events -GROUP BY event -ORDER BY SUM(pg_column_size(telemetry_events.*)) DESC; -``` - -## Success Metrics - -Track these metrics weekly to ensure the system is working: - -### Target Metrics (After Implementation) - -- โœ… Database size: **< 150 MB** (< 30% of limit) -- โœ… Growth rate: **< 3 MB/day** (sustainable) -- โœ… Raw event retention: **3 days** (configurable) -- โœ… Aggregated data: **All-time insights available** -- โœ… Cron job success rate: **> 95%** -- โœ… Query performance: **< 500ms for aggregated queries** - -### Review Schedule - -- **Daily**: Check `check_database_size()` status -- **Weekly**: Review aggregated insights and growth trends -- **Monthly**: Analyze cron job success rate and adjust retention if needed -- **After each release**: Compare usage patterns to previous version - -## Quick Reference - -### Essential Commands - -```sql --- Check database health -SELECT * FROM check_database_size(); - --- View recent aggregated insights -SELECT * FROM telemetry_tool_usage_daily ORDER BY aggregation_date DESC LIMIT 10; - --- Run manual cleanup (3-day retention) -SELECT * FROM run_telemetry_aggregation_and_cleanup(3); -VACUUM ANALYZE telemetry_events; - --- Emergency cleanup (7-day retention) -SELECT * FROM emergency_cleanup(); -VACUUM FULL telemetry_events; - --- View cron job status -SELECT * FROM cron.job WHERE jobname = 'telemetry-daily-cleanup'; - --- View cron execution history -SELECT * FROM cron.job_run_details -WHERE jobid = (SELECT jobid FROM cron.job WHERE jobname = 'telemetry-daily-cleanup') -ORDER BY start_time DESC LIMIT 5; -``` - -## Support - -If you encounter issues: - -1. Check the troubleshooting section above -2. Review cron job execution logs -3. Verify pg_cron extension is enabled -4. Test aggregation functions manually -5. Check Supabase dashboard for errors - -For questions or improvements, refer to the main project documentation. diff --git a/data/nodes.db b/data/nodes.db index c21a5e8..99e1de6 100644 Binary files a/data/nodes.db and b/data/nodes.db differ diff --git a/docs/MULTI_APP_INTEGRATION.md b/docs/MULTI_APP_INTEGRATION.md deleted file mode 100644 index 5243bcc..0000000 --- a/docs/MULTI_APP_INTEGRATION.md +++ /dev/null @@ -1,83 +0,0 @@ -# Multi-App Integration Guide - -This guide explains how session restoration works in n8n-mcp for multi-tenant deployments. - -## Session Restoration: Warm Start Pattern - -When a container restarts, existing client sessions are lost. The warm start pattern allows clients to seamlessly restore sessions without manual intervention. - -### How It Works - -1. **Client sends request** with existing session ID after restart -2. **Server detects** unknown session ID -3. **Restoration hook** is called to load session context from your database -4. **New session created** using restored context -5. **Current request handled** immediately through new transport -6. **Client receives** standard MCP error `-32000` (Server not initialized) -7. **Client auto-retries** with initialize request on same connection -8. **Session fully restored** and client continues normally - -### Key Features - -- **Zero client changes**: Standard MCP clients auto-retry on -32000 -- **Single HTTP round-trip**: No extra network requests needed -- **Concurrent-safe**: Idempotency guards prevent duplicate restoration -- **Automatic cleanup**: Failed restorations clean up resources automatically - -### Implementation - -```typescript -import { SingleSessionHTTPServer } from 'n8n-mcp'; - -const server = new SingleSessionHTTPServer({ - // Hook to load session context from your storage - onSessionNotFound: async (sessionId) => { - const session = await database.loadSession(sessionId); - if (!session || session.expired) { - return null; // Reject restoration - } - return session.instanceContext; // Restore session - }, - - // Optional: Configure timeouts and retries - sessionRestorationTimeout: 5000, // 5 seconds (default) - sessionRestorationRetries: 2, // Retry on transient failures - sessionRestorationRetryDelay: 100 // Delay between retries -}); -``` - -### Session Lifecycle Events - -Track session restoration for metrics and debugging: - -```typescript -const server = new SingleSessionHTTPServer({ - sessionEvents: { - onSessionRestored: (sessionId, context) => { - console.log(`Session ${sessionId} restored`); - metrics.increment('session.restored'); - } - } -}); -``` - -### Error Handling - -The restoration hook can return three outcomes: - -- **Return context**: Session is restored successfully -- **Return null/undefined**: Session is rejected (client gets 400 Bad Request) -- **Throw error**: Restoration failed (client gets 500 Internal Server Error) - -Timeout errors are never retried (already took too long). - -### Concurrency Safety - -Multiple concurrent requests for the same session ID are handled safely: - -- First request triggers restoration -- Subsequent requests reuse the restored session -- No duplicate session creation -- No race conditions - -This ensures correct behavior even under high load or network retries. diff --git a/docs/bugfix-onSessionCreated-event.md b/docs/bugfix-onSessionCreated-event.md deleted file mode 100644 index 0f61452..0000000 --- a/docs/bugfix-onSessionCreated-event.md +++ /dev/null @@ -1,180 +0,0 @@ -# Bug Fix: onSessionCreated Event Not Firing (v2.19.0) - -## Summary - -Fixed critical bug where `onSessionCreated` lifecycle event was never emitted for sessions created during the standard MCP initialize flow, completely breaking session persistence functionality. - -## Impact - -- **Severity**: Critical -- **Affected Version**: v2.19.0 -- **Component**: Session Persistence (Phase 3) -- **Status**: โœ… Fixed - -## Root Cause - -The `handleRequest()` method in `http-server-single-session.ts` had two different paths for session creation: - -1. **Standard initialize flow** (lines 868-943): Created session inline but **did not emit** `onSessionCreated` event -2. **Manual restoration flow** (line 1048): Called `createSession()` which **correctly emitted** the event - -This inconsistency meant that: -- New sessions during normal operation were **never saved to database** -- Only manually restored sessions triggered the save event -- Session persistence was completely broken for new sessions -- Container restarts caused all sessions to be lost - -## The Fix - -### Location -- **File**: `src/http-server-single-session.ts` -- **Method**: `handleRequest()` -- **Line**: After line 943 (`await server.connect(transport);`) - -### Code Change - -Added event emission after successfully connecting server to transport during initialize flow: - -```typescript -// Connect the server to the transport BEFORE handling the request -logger.info('handleRequest: Connecting server to new transport'); -await server.connect(transport); - -// Phase 3: Emit onSessionCreated event (REQ-4) -// Fire-and-forget: don't await or block session creation -this.emitEvent('onSessionCreated', sessionIdToUse, instanceContext).catch(eventErr => { - logger.error('Failed to emit onSessionCreated event (non-blocking)', { - sessionId: sessionIdToUse, - error: eventErr instanceof Error ? eventErr.message : String(eventErr) - }); -}); -``` - -### Why This Works - -1. **Consistent with existing pattern**: Matches the `createSession()` method pattern (line 664) -2. **Non-blocking**: Uses `.catch()` to ensure event handler errors don't break session creation -3. **Correct timing**: Fires after `server.connect(transport)` succeeds, ensuring session is fully initialized -4. **Same parameters**: Passes `sessionId` and `instanceContext` just like the restoration flow - -## Verification - -### Test Results - -Created comprehensive test suite to verify the fix: - -**Test File**: `tests/unit/session/onSessionCreated-event.test.ts` - -**Test Results**: -``` -โœ“ onSessionCreated Event - Initialize Flow - โœ“ should emit onSessionCreated event when session is created during initialize flow (1594ms) - -Test Files 5 passed (5) -Tests 78 passed (78) -``` - -**Manual Testing**: -```typescript -const server = new SingleSessionHTTPServer({ - sessionEvents: { - onSessionCreated: async (sessionId, context) => { - console.log('โœ… Event fired:', sessionId); - await saveSessionToDatabase(sessionId, context); - } - } -}); - -// Result: Event fires successfully on initialize! -// โœ… Event fired: 40dcc123-46bd-4994-945e-f2dbe60e54c2 -``` - -### Behavior After Fix - -1. **Initialize request** โ†’ Session created โ†’ `onSessionCreated` event fired โ†’ Session saved to database โœ… -2. **Session restoration** โ†’ `createSession()` called โ†’ `onSessionCreated` event fired โ†’ Session saved to database โœ… -3. **Manual restoration** โ†’ `manuallyRestoreSession()` โ†’ Session created โ†’ Event fired โœ… - -All three paths now correctly emit the event! - -## Backward Compatibility - -โœ… **Fully backward compatible**: -- No breaking changes to API -- Event handler is optional (defaults to no-op) -- Non-blocking implementation ensures session creation succeeds even if handler fails -- Matches existing behavior of `createSession()` method -- All existing tests pass - -## Related Code - -### Event Emission Points - -1. โœ… **Standard initialize flow**: `handleRequest()` at line ~947 (NEW - fixed) -2. โœ… **Manual restoration**: `createSession()` at line 664 (EXISTING - working) -3. โœ… **Session restoration**: calls `createSession()` indirectly (EXISTING - working) - -### Other Lifecycle Events - -The following events are working correctly: -- `onSessionRestored`: Fires when session is restored from database -- `onSessionAccessed`: Fires on every request (with throttling recommended) -- `onSessionExpired`: Fires before expired session cleanup -- `onSessionDeleted`: Fires on manual session deletion - -## Testing Recommendations - -After applying this fix, verify session persistence works: - -```typescript -// 1. Start server with session events -const engine = new N8NMCPEngine({ - sessionEvents: { - onSessionCreated: async (sessionId, context) => { - await database.upsertSession({ sessionId, ...context }); - } - } -}); - -// 2. Client connects and initializes -// 3. Verify session saved to database -const sessions = await database.query('SELECT * FROM mcp_sessions'); -expect(sessions.length).toBeGreaterThan(0); - -// 4. Restart server -await engine.shutdown(); -await engine.start(); - -// 5. Client reconnects with old session ID -// 6. Verify session restored from database -``` - -## Impact on n8n-mcp-backend - -This fix **unblocks** the multi-tenant n8n-mcp-backend service that depends on session persistence: - -- โœ… Sessions now persist across container restarts -- โœ… Users no longer need to restart Claude Desktop after backend updates -- โœ… Session continuity maintained for all users -- โœ… Production deployment viable - -## Lessons Learned - -1. **Consistency is critical**: Session creation should follow the same pattern everywhere -2. **Event-driven architecture**: Events must fire at all creation points, not just some -3. **Testing lifecycle events**: Need integration tests that verify events fire, not just that code runs -4. **Documentation**: Clearly document when events should fire and where - -## Files Changed - -- `src/http-server-single-session.ts`: Added event emission (lines 945-952) -- `tests/unit/session/onSessionCreated-event.test.ts`: New test file -- `tests/integration/session/test-onSessionCreated-event.ts`: Manual verification test - -## Build Status - -- โœ… TypeScript compilation: Success -- โœ… Type checking: Success -- โœ… All unit tests: 78 passed -- โœ… Integration tests: Pass -- โœ… Backward compatibility: Verified diff --git a/package.json b/package.json index 497e994..640af48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.19.5", + "version": "2.18.10", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/package.runtime.json b/package.runtime.json index e244f81..cba65f5 100644 --- a/package.runtime.json +++ b/package.runtime.json @@ -1,17 +1,8 @@ { "name": "n8n-mcp-runtime", - "version": "2.19.5", + "version": "2.18.10", "description": "n8n MCP Server Runtime Dependencies Only", "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.js", - "import": "./dist/index.js" - } - }, "dependencies": { "@modelcontextprotocol/sdk": "^1.13.2", "@supabase/supabase-js": "^2.57.4", diff --git a/src/http-server-single-session.ts b/src/http-server-single-session.ts index d2f8ac5..17716d1 100644 --- a/src/http-server-single-session.ts +++ b/src/http-server-single-session.ts @@ -25,7 +25,6 @@ import { STANDARD_PROTOCOL_VERSION } from './utils/protocol-version'; import { InstanceContext, validateInstanceContext } from './types/instance-context'; -import { SessionRestoreHook, SessionState, SessionLifecycleEvents } from './types/session-restoration'; dotenv.config(); @@ -85,53 +84,12 @@ export class SingleSessionHTTPServer { private sessionTimeout = 30 * 60 * 1000; // 30 minutes private authToken: string | null = null; private cleanupTimer: NodeJS.Timeout | null = null; - - // Recursion guard to prevent concurrent cleanup of same session - private cleanupInProgress = new Set(); - - // Shutdown flag to prevent recursive event handlers during cleanup - private isShuttingDown = false; - - // Session restoration options (Phase 1 - v2.19.0) - private onSessionNotFound?: SessionRestoreHook; - private sessionRestorationTimeout: number; - - // Session lifecycle events (Phase 3 - v2.19.0) - private sessionEvents?: SessionLifecycleEvents; - - // Retry policy (Phase 4 - v2.19.0) - private sessionRestorationRetries: number; - private sessionRestorationRetryDelay: number; - - constructor(options: { - sessionTimeout?: number; - onSessionNotFound?: SessionRestoreHook; - sessionRestorationTimeout?: number; - sessionEvents?: SessionLifecycleEvents; - sessionRestorationRetries?: number; - sessionRestorationRetryDelay?: number; - } = {}) { + + constructor() { // Validate environment on construction this.validateEnvironment(); - - // Session restoration configuration - this.onSessionNotFound = options.onSessionNotFound; - this.sessionRestorationTimeout = options.sessionRestorationTimeout || 5000; // 5 seconds default - - // Lifecycle events configuration - this.sessionEvents = options.sessionEvents; - - // Retry policy configuration - this.sessionRestorationRetries = options.sessionRestorationRetries ?? 0; // Default: no retries - this.sessionRestorationRetryDelay = options.sessionRestorationRetryDelay || 100; // Default: 100ms - - // Override session timeout if provided - if (options.sessionTimeout) { - this.sessionTimeout = options.sessionTimeout; - } - // No longer pre-create session - will be created per initialize request following SDK pattern - + // Start periodic session cleanup this.startSessionCleanup(); } @@ -157,9 +115,8 @@ export class SingleSessionHTTPServer { /** * Clean up expired sessions based on last access time - * CRITICAL: Now async to properly await cleanup operations */ - private async cleanupExpiredSessions(): Promise { + private cleanupExpiredSessions(): void { const now = Date.now(); const expiredSessions: string[] = []; @@ -180,140 +137,38 @@ export class SingleSessionHTTPServer { } } - // Check for orphaned transports (transports without metadata) - for (const sessionId in this.transports) { - if (!this.sessionMetadata[sessionId]) { - logger.warn('Orphaned transport detected, cleaning up', { sessionId }); - try { - // Await cleanup to prevent concurrent operations - await this.removeSession(sessionId, 'orphaned_transport'); - } catch (err) { - logger.error('Error cleaning orphaned transport', { - sessionId, - error: err instanceof Error ? err.message : String(err) - }); - } - } - } - - // Check for orphaned servers (servers without metadata) - for (const sessionId in this.servers) { - if (!this.sessionMetadata[sessionId]) { - logger.warn('Orphaned server detected, cleaning up', { sessionId }); - delete this.servers[sessionId]; - logger.debug('Cleaned orphaned server', { sessionId }); - } - } - - // Remove expired sessions SEQUENTIALLY with error isolation - // CRITICAL: Must await each removeSession call to prevent concurrent cleanup - // and stack overflow from recursive cleanup attempts - let successCount = 0; - let failureCount = 0; - + // Remove expired sessions for (const sessionId of expiredSessions) { - try { - // Phase 3: Emit onSessionExpired event BEFORE removal (REQ-4) - // Await the event to ensure it completes before cleanup - await this.emitEvent('onSessionExpired', sessionId); - - // CRITICAL: MUST await to prevent concurrent cleanup - await this.removeSession(sessionId, 'expired'); - successCount++; - } catch (error) { - // Isolate error - don't let one session failure stop cleanup of others - failureCount++; - logger.error('Failed to cleanup expired session (isolated)', { - sessionId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); - // Continue with next session - cleanup must be resilient - } + this.removeSession(sessionId, 'expired'); } if (expiredSessions.length > 0) { - logger.info('Expired session cleanup completed', { - total: expiredSessions.length, - successful: successCount, - failed: failureCount, + logger.info('Cleaned up expired sessions', { + removed: expiredSessions.length, remaining: this.getActiveSessionCount() }); } } - /** - * Safely close a transport without triggering recursive cleanup - * Removes event handlers and uses timeout to prevent hanging - */ - private async safeCloseTransport(sessionId: string): Promise { - const transport = this.transports[sessionId]; - if (!transport) return; - - try { - // Remove event handlers to prevent recursion during cleanup - // This is critical to break the circular call chain - transport.onclose = undefined; - transport.onerror = undefined; - - // Close with timeout protection (3 seconds) - const closePromise = transport.close(); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Transport close timeout')), 3000) - ); - - await Promise.race([closePromise, timeoutPromise]); - logger.debug('Transport closed safely', { sessionId }); - } catch (error) { - // Log but don't throw - cleanup must continue even if close fails - logger.warn('Transport close error (non-fatal)', { - sessionId, - error: error instanceof Error ? error.message : String(error) - }); - } - } - /** * Remove a session and clean up resources - * Protected against concurrent cleanup attempts via recursion guard */ private async removeSession(sessionId: string, reason: string): Promise { - // CRITICAL: Guard against concurrent cleanup of the same session - // This prevents stack overflow from recursive cleanup attempts - if (this.cleanupInProgress.has(sessionId)) { - logger.debug('Cleanup already in progress, skipping duplicate', { - sessionId, - reason - }); - return; - } - - // Mark session as being cleaned up - this.cleanupInProgress.add(sessionId); - try { - // Close transport safely if exists (with timeout and no recursion) + // Close transport if exists if (this.transports[sessionId]) { - await this.safeCloseTransport(sessionId); + await this.transports[sessionId].close(); delete this.transports[sessionId]; } - + // Remove server, metadata, and context delete this.servers[sessionId]; delete this.sessionMetadata[sessionId]; delete this.sessionContexts[sessionId]; - - logger.info('Session removed successfully', { sessionId, reason }); + + logger.info('Session removed', { sessionId, reason }); } catch (error) { - logger.warn('Error during session removal', { - sessionId, - reason, - error: error instanceof Error ? error.message : String(error) - }); - } finally { - // CRITICAL: Always remove from cleanup set, even on error - // This prevents sessions from being permanently stuck in "cleaning" state - this.cleanupInProgress.delete(sessionId); + logger.warn('Error removing session', { sessionId, reason, error }); } } @@ -332,44 +187,23 @@ export class SingleSessionHTTPServer { } /** - * Validate session ID format (Security-Hardened - REQ-8) + * Validate session ID format * - * Validates session ID format to prevent injection attacks: - * - SQL injection - * - NoSQL injection - * - Path traversal - * - DoS via oversized IDs + * Accepts any non-empty string to support various MCP clients: + * - UUIDv4 (internal n8n-mcp format) + * - instance-{userId}-{hash}-{uuid} (multi-tenant format) + * - Custom formats from mcp-remote and other proxies * - * Accepts any non-empty string with safe characters for MCP client compatibility. - * Security protections: - * - Character whitelist: Only alphanumeric, hyphens, and underscores allowed - * - Maximum length: 100 characters (DoS protection) - * - Rejects empty strings + * Security: Session validation happens via lookup in this.transports, + * not format validation. This ensures compatibility with all MCP clients. * * @param sessionId - Session identifier from MCP client * @returns true if valid, false otherwise - * @since 2.19.0 - Enhanced with security validation - * @since 2.19.1 - Relaxed to accept any non-empty safe string */ private isValidSessionId(sessionId: string): boolean { - if (!sessionId || typeof sessionId !== 'string') { - return false; - } - - // Character whitelist (alphanumeric + hyphens + underscores) - Injection protection - // Prevents SQL/NoSQL injection and path traversal attacks - if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) { - return false; - } - - // Maximum length validation for DoS protection - // Prevents memory exhaustion from oversized session IDs - if (sessionId.length > 100) { - return false; - } - - // Accept any non-empty string that passes the security checks above - return true; + // Accept any non-empty string as session ID + // This ensures compatibility with all MCP clients and proxies + return Boolean(sessionId && sessionId.length > 0); } /** @@ -412,16 +246,6 @@ export class SingleSessionHTTPServer { private updateSessionAccess(sessionId: string): void { if (this.sessionMetadata[sessionId]) { this.sessionMetadata[sessionId].lastAccess = new Date(); - - // Phase 3: Emit onSessionAccessed event (REQ-4) - // Fire-and-forget: don't await or block request processing - // IMPORTANT: This fires on EVERY request - implement throttling in your handler! - this.emitEvent('onSessionAccessed', sessionId).catch(err => { - logger.error('Failed to emit onSessionAccessed event (non-blocking)', { - sessionId, - error: err instanceof Error ? err.message : String(err) - }); - }); } } @@ -473,345 +297,6 @@ export class SingleSessionHTTPServer { } } - /** - * Timeout utility for session restoration - * Creates a promise that rejects after the specified milliseconds - * - * @param ms - Timeout duration in milliseconds - * @returns Promise that rejects with TimeoutError - * @since 2.19.0 - */ - private timeout(ms: number): Promise { - return new Promise((_, reject) => { - setTimeout(() => { - const error = new Error(`Operation timed out after ${ms}ms`); - error.name = 'TimeoutError'; - reject(error); - }, ms); - }); - } - - /** - * Emit a session lifecycle event (Phase 3 - REQ-4) - * Errors in event handlers are logged but don't break session operations - * - * @param eventName - The event to emit - * @param args - Arguments to pass to the event handler - * @since 2.19.0 - */ - private async emitEvent( - eventName: keyof SessionLifecycleEvents, - ...args: [string, InstanceContext?] - ): Promise { - const handler = this.sessionEvents?.[eventName] as (((...args: any[]) => void | Promise) | undefined); - if (!handler) return; - - try { - // Support both sync and async handlers - await Promise.resolve(handler(...args)); - } catch (error) { - logger.error(`Session event handler failed: ${eventName}`, { - error: error instanceof Error ? error.message : String(error), - sessionId: args[0] // First arg is always sessionId - }); - // DON'T THROW - event failures shouldn't break session operations - } - } - - /** - * Restore session with retry policy (Phase 4 - REQ-7) - * - * Attempts to restore a session using the onSessionNotFound hook, - * with configurable retry logic for transient failures. - * - * Timeout applies to ALL attempts combined (not per attempt). - * Timeout errors are never retried. - * - * @param sessionId - Session ID to restore - * @returns Restored instance context or null - * @throws TimeoutError if overall timeout exceeded - * @throws Error from hook if all retry attempts failed - * @since 2.19.0 - */ - private async restoreSessionWithRetry(sessionId: string): Promise { - if (!this.onSessionNotFound) { - throw new Error('onSessionNotFound hook not configured'); - } - - const maxRetries = this.sessionRestorationRetries; - const retryDelay = this.sessionRestorationRetryDelay; - const overallTimeout = this.sessionRestorationTimeout; - const startTime = Date.now(); - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - // Calculate remaining time for this attempt - const remainingTime = overallTimeout - (Date.now() - startTime); - - if (remainingTime <= 0) { - const error = new Error(`Session restoration timed out after ${overallTimeout}ms`); - error.name = 'TimeoutError'; - throw error; - } - - // Log retry attempt (except first attempt) - if (attempt > 0) { - logger.debug('Retrying session restoration', { - sessionId, - attempt: attempt, - maxRetries: maxRetries, - remainingTime: remainingTime + 'ms' - }); - } - - // Call hook with remaining time as timeout - const context = await Promise.race([ - this.onSessionNotFound(sessionId), - this.timeout(remainingTime) - ]); - - // Success! - if (attempt > 0) { - logger.info('Session restoration succeeded after retry', { - sessionId, - attempts: attempt + 1 - }); - } - - return context; - - } catch (error) { - // Don't retry timeout errors (already took too long) - if (error instanceof Error && error.name === 'TimeoutError') { - logger.error('Session restoration timeout (no retry)', { - sessionId, - timeout: overallTimeout - }); - throw error; - } - - // Last attempt - don't delay, just throw - if (attempt === maxRetries) { - logger.error('Session restoration failed after all retries', { - sessionId, - attempts: attempt + 1, - error: error instanceof Error ? error.message : String(error) - }); - throw error; - } - - // Log retry-eligible failure - logger.warn('Session restoration failed, will retry', { - sessionId, - attempt: attempt + 1, - maxRetries: maxRetries, - error: error instanceof Error ? error.message : String(error), - nextRetryIn: retryDelay + 'ms' - }); - - // Delay before next attempt - await new Promise(resolve => setTimeout(resolve, retryDelay)); - } - } - - // Should never reach here, but TypeScript needs it - throw new Error('Unexpected state in restoreSessionWithRetry'); - } - - /** - * Create a new session (IDEMPOTENT - REQ-2) - * - * This method is idempotent to prevent race conditions during concurrent - * restoration attempts. If the session already exists, returns existing - * session ID without creating a duplicate. - * - * @param instanceContext - Instance-specific configuration - * @param sessionId - Optional pre-defined session ID (for restoration) - * @param waitForConnection - If true, waits for server.connect() to complete (for restoration) - * @returns The session ID (newly created or existing) - * @throws Error if session ID format is invalid - * @since 2.19.0 - */ - private createSession( - instanceContext: InstanceContext, - sessionId?: string, - waitForConnection: boolean = false - ): Promise | string { - // Generate session ID if not provided - const id = sessionId || this.generateSessionId(instanceContext); - - // CRITICAL: Idempotency check to prevent race conditions - if (this.transports[id]) { - logger.debug('Session already exists, skipping creation (idempotent)', { - sessionId: id - }); - return waitForConnection ? Promise.resolve(id) : id; - } - - // Validate session ID format if provided externally - if (sessionId && !this.isValidSessionId(sessionId)) { - logger.error('Invalid session ID format during creation', { sessionId }); - throw new Error('Invalid session ID format'); - } - - // Store session metadata immediately for synchronous access - // This ensures getActiveSessions() works immediately after restoreSession() - // Only store if not already stored (idempotency - prevents duplicate storage) - if (!this.sessionMetadata[id]) { - this.sessionMetadata[id] = { - lastAccess: new Date(), - createdAt: new Date() - }; - this.sessionContexts[id] = instanceContext; - } - - const server = new N8NDocumentationMCPServer(instanceContext); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => id, - onsessioninitialized: (initializedSessionId: string) => { - logger.info('Session initialized during explicit creation', { - sessionId: initializedSessionId - }); - } - }); - - // Store transport and server immediately to maintain idempotency for concurrent calls - this.transports[id] = transport; - this.servers[id] = server; - - // Set up cleanup handlers - transport.onclose = () => { - if (transport.sessionId) { - // Prevent recursive cleanup during shutdown - if (this.isShuttingDown) { - logger.debug('Ignoring transport close event during shutdown', { - sessionId: transport.sessionId - }); - return; - } - - logger.info('Transport closed during createSession, cleaning up', { - sessionId: transport.sessionId - }); - this.removeSession(transport.sessionId, 'transport_closed').catch(err => { - logger.error('Error during transport close cleanup', { - sessionId: transport.sessionId, - error: err instanceof Error ? err.message : String(err) - }); - }); - } - }; - - transport.onerror = (error: Error) => { - if (transport.sessionId) { - // Prevent recursive cleanup during shutdown - if (this.isShuttingDown) { - logger.debug('Ignoring transport error event during shutdown', { - sessionId: transport.sessionId - }); - return; - } - - logger.error('Transport error during createSession', { - sessionId: transport.sessionId, - error: error.message - }); - this.removeSession(transport.sessionId, 'transport_error').catch(err => { - logger.error('Error during transport error cleanup', { error: err }); - }); - } - }; - - const initializeSession = async (): Promise => { - try { - // Ensure server is fully initialized before connecting - await (server as any).initialized; - - await server.connect(transport); - - if (waitForConnection) { - logger.info('Session created and connected successfully', { - sessionId: id, - hasInstanceContext: !!instanceContext, - instanceId: instanceContext?.instanceId - }); - } else { - logger.info('Session created successfully (connecting server to transport)', { - sessionId: id, - hasInstanceContext: !!instanceContext, - instanceId: instanceContext?.instanceId - }); - } - } catch (err) { - logger.error('Failed to connect server to transport in createSession', { - sessionId: id, - error: err instanceof Error ? err.message : String(err), - waitForConnection - }); - - await this.removeSession(id, 'connection_failed').catch(cleanupErr => { - logger.error('Error during connection failure cleanup', { error: cleanupErr }); - }); - - throw err; - } - - // Phase 3: Emit onSessionCreated event (REQ-4) - // Fire-and-forget: don't await or block session creation - this.emitEvent('onSessionCreated', id, instanceContext).catch(eventErr => { - logger.error('Failed to emit onSessionCreated event (non-blocking)', { - sessionId: id, - error: eventErr instanceof Error ? eventErr.message : String(eventErr) - }); - }); - - return id; - }; - - if (waitForConnection) { - // Caller expects to wait until connection succeeds - return initializeSession(); - } - - // Fire-and-forget for manual restoration - surface errors via logging/cleanup - initializeSession().catch(error => { - logger.error('Async session creation failed in manual restore flow', { - sessionId: id, - error: error instanceof Error ? error.message : String(error) - }); - }); - - return id; - } - - /** - * Generate session ID based on instance context - * Used for multi-tenant mode - * - * @param instanceContext - Instance-specific configuration - * @returns Generated session ID - */ - private generateSessionId(instanceContext?: InstanceContext): string { - const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; - const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance'; - - if (isMultiTenantEnabled && sessionStrategy === 'instance' && instanceContext?.instanceId) { - // Multi-tenant mode with instance strategy - const configHash = createHash('sha256') - .update(JSON.stringify({ - url: instanceContext.n8nApiUrl, - instanceId: instanceContext.instanceId - })) - .digest('hex') - .substring(0, 8); - - return `instance-${instanceContext.instanceId}-${configHash}-${uuidv4()}`; - } - - // Standard UUIDv4 - return uuidv4(); - } - /** * Get session metrics for monitoring */ @@ -1019,33 +504,16 @@ export class SingleSessionHTTPServer { transport.onclose = () => { const sid = transport.sessionId; if (sid) { - // Prevent recursive cleanup during shutdown - if (this.isShuttingDown) { - logger.debug('Ignoring transport close event during shutdown', { sessionId: sid }); - return; - } - logger.info('handleRequest: Transport closed, cleaning up', { sessionId: sid }); - this.removeSession(sid, 'transport_closed').catch(err => { - logger.error('Error during transport close cleanup', { - sessionId: sid, - error: err instanceof Error ? err.message : String(err) - }); - }); + this.removeSession(sid, 'transport_closed'); } }; - + // Handle transport errors to prevent connection drops transport.onerror = (error: Error) => { const sid = transport.sessionId; + logger.error('Transport error', { sessionId: sid, error: error.message }); if (sid) { - // Prevent recursive cleanup during shutdown - if (this.isShuttingDown) { - logger.debug('Ignoring transport error event during shutdown', { sessionId: sid }); - return; - } - - logger.error('Transport error', { sessionId: sid, error: error.message }); this.removeSession(sid, 'transport_error').catch(err => { logger.error('Error during transport error cleanup', { error: err }); }); @@ -1055,16 +523,7 @@ export class SingleSessionHTTPServer { // Connect the server to the transport BEFORE handling the request logger.info('handleRequest: Connecting server to new transport'); await server.connect(transport); - - // Phase 3: Emit onSessionCreated event (REQ-4) - // Fire-and-forget: don't await or block session creation - this.emitEvent('onSessionCreated', sessionIdToUse, instanceContext).catch(eventErr => { - logger.error('Failed to emit onSessionCreated event (non-blocking)', { - sessionId: sessionIdToUse, - error: eventErr instanceof Error ? eventErr.message : String(eventErr) - }); - }); - + } else if (sessionId && this.transports[sessionId]) { // Validate session ID format if (!this.isValidSessionId(sessionId)) { @@ -1097,184 +556,32 @@ export class SingleSessionHTTPServer { this.updateSessionAccess(sessionId); } else { - // Handle unknown session ID - check if we can restore it - if (sessionId) { - // REQ-8: Validate session ID format FIRST (security) - if (!this.isValidSessionId(sessionId)) { - logger.warn('handleRequest: Invalid session ID format rejected', { - sessionId: sessionId.substring(0, 20) - }); - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32602, - message: 'Invalid session ID format' - }, - id: req.body?.id || null - }); - return; - } - - // REQ-1: Try session restoration if hook provided - if (this.onSessionNotFound) { - logger.info('Attempting session restoration', { sessionId }); - - try { - // REQ-7: Call restoration with retry policy (Phase 4) - // restoreSessionWithRetry handles timeout and retries internally - const restoredContext = await this.restoreSessionWithRetry(sessionId); - - // Handle both null and undefined defensively - // Both indicate the hook declined to restore the session - if (restoredContext === null || restoredContext === undefined) { - logger.info('Session restoration declined by hook', { - sessionId, - returnValue: restoredContext === null ? 'null' : 'undefined' - }); - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Session not found or expired' - }, - id: req.body?.id || null - }); - return; - } - - // Validate the context returned by the hook - const validation = validateInstanceContext(restoredContext); - if (!validation.valid) { - logger.error('Invalid context returned from restoration hook', { - sessionId, - errors: validation.errors - }); - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Invalid session context' - }, - id: req.body?.id || null - }); - return; - } - - // Warm Start: Guard against concurrent restoration attempts - // If another request is already creating this session, reuse it - if (this.transports[sessionId]) { - logger.info('Session already restored by concurrent request', { sessionId }); - transport = this.transports[sessionId]; - } else { - // Create session using existing createSession() flow - // This creates transport and server with all proper event handlers - logger.info('Session restoration successful, creating session', { - sessionId, - instanceId: restoredContext.instanceId - }); - - // Create session (returns sessionId synchronously) - // The transport is stored immediately in this.transports[sessionId] - this.createSession(restoredContext, sessionId, false); - - // Get the transport that was just created - transport = this.transports[sessionId]; - if (!transport) { - throw new Error('Transport not found after session creation'); - } - } - - // Emit onSessionRestored event (fire-and-forget, non-blocking) - this.emitEvent('onSessionRestored', sessionId, restoredContext).catch(err => { - logger.error('Failed to emit onSessionRestored event (non-blocking)', { - sessionId, - error: err instanceof Error ? err.message : String(err) - }); - }); - - // Handle current request through the new transport immediately - // This allows the client to re-initialize on the same connection - logger.info('Handling request through restored session transport', { sessionId }); - await transport.handleRequest(req, res, req.body); - - // CRITICAL: Early return to prevent double processing - // The transport has already sent the response - return; - - } catch (error) { - // Clean up session on restoration failure - if (this.transports[sessionId]) { - logger.info('Cleaning up failed session restoration', { sessionId }); - await this.removeSession(sessionId, 'restoration_failed').catch(cleanupErr => { - logger.error('Error during restoration failure cleanup', { - sessionId, - error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr) - }); - }); - } - - // Handle timeout - if (error instanceof Error && error.name === 'TimeoutError') { - logger.error('Session restoration timeout', { - sessionId, - timeout: this.sessionRestorationTimeout - }); - res.status(408).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Session restoration timeout' - }, - id: req.body?.id || null - }); - return; - } - - // Handle other errors - logger.error('Session restoration failed', { - sessionId, - error: error instanceof Error ? error.message : String(error) - }); - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Session restoration failed' - }, - id: req.body?.id || null - }); - return; - } - } else { - // No restoration hook - session not found - logger.warn('Session not found and no restoration hook configured', { - sessionId - }); - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Session not found or expired' - }, - id: req.body?.id || null - }); - return; - } - } else { - // No session ID and not initialize - invalid request - logger.warn('handleRequest: Invalid request - no session ID and not initialize', { - isInitialize - }); - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided and not an initialize request' - }, - id: req.body?.id || null - }); - return; + // Invalid request - no session ID and not an initialize request + const errorDetails = { + hasSessionId: !!sessionId, + isInitialize: isInitialize, + sessionIdValid: sessionId ? this.isValidSessionId(sessionId) : false, + sessionExists: sessionId ? !!this.transports[sessionId] : false + }; + + logger.warn('handleRequest: Invalid request - no session ID and not initialize', errorDetails); + + let errorMessage = 'Bad Request: No valid session ID provided and not an initialize request'; + if (sessionId && !this.isValidSessionId(sessionId)) { + errorMessage = 'Bad Request: Invalid session ID format'; + } else if (sessionId && !this.transports[sessionId]) { + errorMessage = 'Bad Request: Session not found or expired'; } + + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: errorMessage + }, + id: req.body?.id || null + }); + return; } // Handle request with the transport @@ -2002,51 +1309,29 @@ export class SingleSessionHTTPServer { /** * Graceful shutdown - * CRITICAL: Sets isShuttingDown flag to prevent recursive cleanup */ async shutdown(): Promise { logger.info('Shutting down Single-Session HTTP server...'); - - // CRITICAL: Set shutdown flag FIRST to prevent recursive event handlers - // This stops transport.onclose/onerror from triggering removeSession during cleanup - this.isShuttingDown = true; - logger.info('Shutdown flag set - recursive cleanup prevention enabled'); - + // Stop session cleanup timer if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; logger.info('Session cleanup timer stopped'); } - - // Close all active transports (SDK pattern) with error isolation + + // Close all active transports (SDK pattern) const sessionIds = Object.keys(this.transports); logger.info(`Closing ${sessionIds.length} active sessions`); - - let successCount = 0; - let failureCount = 0; - + for (const sessionId of sessionIds) { try { logger.info(`Closing transport for session ${sessionId}`); await this.removeSession(sessionId, 'server_shutdown'); - successCount++; } catch (error) { - failureCount++; - logger.warn(`Error closing transport for session ${sessionId}:`, { - error: error instanceof Error ? error.message : String(error) - }); - // Continue with next session - shutdown must complete + logger.warn(`Error closing transport for session ${sessionId}:`, error); } } - - if (sessionIds.length > 0) { - logger.info('Session shutdown completed', { - total: sessionIds.length, - successful: successCount, - failed: failureCount - }); - } // Clean up legacy session (for SSE compatibility) if (this.session) { @@ -2075,9 +1360,9 @@ export class SingleSessionHTTPServer { /** * Get current session info (for testing/debugging) */ - getSessionInfo(): { - active: boolean; - sessionId?: string; + getSessionInfo(): { + active: boolean; + sessionId?: string; age?: number; sessions?: { total: number; @@ -2088,10 +1373,10 @@ export class SingleSessionHTTPServer { }; } { const metrics = this.getSessionMetrics(); - + // Legacy SSE session info if (!this.session) { - return { + return { active: false, sessions: { total: metrics.totalSessions, @@ -2102,7 +1387,7 @@ export class SingleSessionHTTPServer { } }; } - + return { active: true, sessionId: this.session.sessionId, @@ -2116,240 +1401,6 @@ export class SingleSessionHTTPServer { } }; } - - /** - * Get all active session IDs (Phase 2 - REQ-5) - * Useful for periodic backup to database - * - * @returns Array of active session IDs - * @since 2.19.0 - * - * @example - * ```typescript - * const sessionIds = server.getActiveSessions(); - * console.log(`Active sessions: ${sessionIds.length}`); - * ``` - */ - getActiveSessions(): string[] { - // Use sessionMetadata instead of transports for immediate synchronous access - // Metadata is stored immediately, while transports are created asynchronously - return Object.keys(this.sessionMetadata); - } - - /** - * Get session state for persistence (Phase 2 - REQ-5) - * Returns null if session doesn't exist - * - * @param sessionId - The session ID to retrieve state for - * @returns Session state or null if not found - * @since 2.19.0 - * - * @example - * ```typescript - * const state = server.getSessionState('session-123'); - * if (state) { - * await database.saveSession(state); - * } - * ``` - */ - getSessionState(sessionId: string): SessionState | null { - // Check if session metadata exists (source of truth for session existence) - const metadata = this.sessionMetadata[sessionId]; - if (!metadata) { - return null; - } - - const instanceContext = this.sessionContexts[sessionId]; - - // Calculate expiration time - const expiresAt = new Date(metadata.lastAccess.getTime() + this.sessionTimeout); - - return { - sessionId, - instanceContext: instanceContext || { - n8nApiUrl: process.env.N8N_API_URL, - n8nApiKey: process.env.N8N_API_KEY, - instanceId: process.env.N8N_INSTANCE_ID - }, - createdAt: metadata.createdAt, - lastAccess: metadata.lastAccess, - expiresAt, - metadata: instanceContext?.metadata - }; - } - - /** - * Get all session states (Phase 2 - REQ-5) - * Useful for bulk backup operations - * - * @returns Array of all session states - * @since 2.19.0 - * - * @example - * ```typescript - * // Periodic backup every 5 minutes - * setInterval(async () => { - * const states = server.getAllSessionStates(); - * for (const state of states) { - * await database.upsertSession(state); - * } - * }, 300000); - * ``` - */ - getAllSessionStates(): SessionState[] { - const sessionIds = this.getActiveSessions(); - const states: SessionState[] = []; - - for (const sessionId of sessionIds) { - const state = this.getSessionState(sessionId); - if (state) { - states.push(state); - } - } - - return states; - } - - /** - * Manually restore a session (Phase 2 - REQ-5) - * Creates a session with the given ID and instance context - * Idempotent - returns true even if session already exists - * - * @param sessionId - The session ID to restore - * @param instanceContext - Instance configuration for the session - * @returns true if session was created or already exists, false on validation error - * @since 2.19.0 - * - * @example - * ```typescript - * // Restore session from database - * const restored = server.manuallyRestoreSession( - * 'session-123', - * { n8nApiUrl: '...', n8nApiKey: '...', instanceId: 'user-456' } - * ); - * console.log(`Session restored: ${restored}`); - * ``` - */ - manuallyRestoreSession(sessionId: string, instanceContext: InstanceContext): boolean { - try { - // Validate session ID format - if (!this.isValidSessionId(sessionId)) { - logger.error('Invalid session ID format in manual restoration', { sessionId }); - return false; - } - - // Validate instance context - const validation = validateInstanceContext(instanceContext); - if (!validation.valid) { - logger.error('Invalid instance context in manual restoration', { - sessionId, - errors: validation.errors - }); - return false; - } - - // CRITICAL: Store metadata immediately for synchronous access - // This ensures getActiveSessions() and deleteSession() work immediately after calling this method - // The session is "registered" even though the connection happens asynchronously - this.sessionMetadata[sessionId] = { - lastAccess: new Date(), - createdAt: new Date() - }; - this.sessionContexts[sessionId] = instanceContext; - - // Create session asynchronously (connection happens in background) - // Don't wait for connection - this is for public API, connection happens async - // Fire-and-forget: start the async operation but don't block - const creationResult = this.createSession(instanceContext, sessionId, false); - Promise.resolve(creationResult).catch(error => { - logger.error('Async session creation failed in manual restoration', { - sessionId, - error: error instanceof Error ? error.message : String(error) - }); - // Clean up metadata on error - delete this.sessionMetadata[sessionId]; - delete this.sessionContexts[sessionId]; - }); - - logger.info('Session manually restored', { - sessionId, - instanceId: instanceContext.instanceId - }); - - return true; - } catch (error) { - logger.error('Failed to manually restore session', { - sessionId, - error: error instanceof Error ? error.message : String(error) - }); - return false; - } - } - - /** - * Manually delete a session (Phase 2 - REQ-5) - * Removes the session and cleans up all resources - * - * @param sessionId - The session ID to delete - * @returns true if session was deleted, false if session didn't exist - * @since 2.19.0 - * - * @example - * ```typescript - * // Delete expired sessions - * const deleted = server.manuallyDeleteSession('session-123'); - * if (deleted) { - * console.log('Session deleted successfully'); - * } - * ``` - */ - manuallyDeleteSession(sessionId: string): boolean { - // Check if session exists (check metadata, not transport) - // Metadata is stored immediately when session is created/restored - // Transport is created asynchronously, so it might not exist yet - if (!this.sessionMetadata[sessionId]) { - logger.debug('Session not found for manual deletion', { sessionId }); - return false; - } - - // CRITICAL: Delete session data synchronously for unit tests - // Close transport asynchronously in background, but remove from maps immediately - try { - // Close transport asynchronously (non-blocking) if it exists - if (this.transports[sessionId]) { - this.transports[sessionId].close().catch(error => { - logger.warn('Error closing transport during manual deletion', { - sessionId, - error: error instanceof Error ? error.message : String(error) - }); - }); - } - - // Phase 3: Emit onSessionDeleted event BEFORE removal (REQ-4) - // Fire-and-forget: don't await or block deletion - this.emitEvent('onSessionDeleted', sessionId).catch(err => { - logger.error('Failed to emit onSessionDeleted event (non-blocking)', { - sessionId, - error: err instanceof Error ? err.message : String(err) - }); - }); - - // Remove session data immediately (synchronous) - delete this.transports[sessionId]; - delete this.servers[sessionId]; - delete this.sessionMetadata[sessionId]; - delete this.sessionContexts[sessionId]; - - logger.info('Session manually deleted', { sessionId }); - return true; - } catch (error) { - logger.error('Error during manual session deletion', { - sessionId, - error: error instanceof Error ? error.message : String(error) - }); - return false; - } - } } // Start if called directly @@ -2384,4 +1435,4 @@ if (require.main === module) { console.error('Failed to start Single-Session HTTP server:', error); process.exit(1); }); -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7ea6504..b5c1005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,13 +19,6 @@ export { isInstanceContext } from './types/instance-context'; -// Session restoration types (v2.19.0) -export type { - SessionRestoreHook, - SessionRestorationOptions, - SessionState -} from './types/session-restoration'; - // Re-export MCP SDK types for convenience export type { Tool, diff --git a/src/mcp-engine.ts b/src/mcp-engine.ts index a519276..d1a6632 100644 --- a/src/mcp-engine.ts +++ b/src/mcp-engine.ts @@ -9,7 +9,6 @@ import { Request, Response } from 'express'; import { SingleSessionHTTPServer } from './http-server-single-session'; import { logger } from './utils/logger'; import { InstanceContext } from './types/instance-context'; -import { SessionRestoreHook, SessionState } from './types/session-restoration'; export interface EngineHealth { status: 'healthy' | 'unhealthy'; @@ -26,71 +25,6 @@ export interface EngineHealth { export interface EngineOptions { sessionTimeout?: number; logLevel?: 'error' | 'warn' | 'info' | 'debug'; - - /** - * Session restoration hook for multi-tenant persistence - * Called when a client tries to use an unknown session ID - * Return instance context to restore the session, or null to reject - * - * @security IMPORTANT: Implement rate limiting in this hook to prevent abuse. - * Malicious clients could trigger excessive database lookups by sending random - * session IDs. Consider using express-rate-limit or similar middleware. - * - * @since 2.19.0 - */ - onSessionNotFound?: SessionRestoreHook; - - /** - * Maximum time to wait for session restoration (milliseconds) - * @default 5000 (5 seconds) - * @since 2.19.0 - */ - sessionRestorationTimeout?: number; - - /** - * Session lifecycle event handlers (Phase 3 - REQ-4) - * - * Optional callbacks for session lifecycle events: - * - onSessionCreated: Called when a new session is created - * - onSessionRestored: Called when a session is restored from storage - * - onSessionAccessed: Called on EVERY request (consider throttling!) - * - onSessionExpired: Called when a session expires - * - onSessionDeleted: Called when a session is manually deleted - * - * All handlers are fire-and-forget (non-blocking). - * Errors are logged but don't affect session operations. - * - * @since 2.19.0 - */ - sessionEvents?: { - onSessionCreated?: (sessionId: string, instanceContext: InstanceContext) => void | Promise; - onSessionRestored?: (sessionId: string, instanceContext: InstanceContext) => void | Promise; - onSessionAccessed?: (sessionId: string) => void | Promise; - onSessionExpired?: (sessionId: string) => void | Promise; - onSessionDeleted?: (sessionId: string) => void | Promise; - }; - - /** - * Number of retry attempts for failed session restoration (Phase 4 - REQ-7) - * - * When the restoration hook throws an error, the system will retry - * up to this many times with a delay between attempts. - * - * Timeout errors are NOT retried (already took too long). - * The overall timeout applies to ALL retry attempts combined. - * - * @default 0 (no retries, opt-in) - * @since 2.19.0 - */ - sessionRestorationRetries?: number; - - /** - * Delay between retry attempts in milliseconds (Phase 4 - REQ-7) - * - * @default 100 (100 milliseconds) - * @since 2.19.0 - */ - sessionRestorationRetryDelay?: number; } export class N8NMCPEngine { @@ -98,9 +32,9 @@ export class N8NMCPEngine { private startTime: Date; constructor(options: EngineOptions = {}) { - this.server = new SingleSessionHTTPServer(options); + this.server = new SingleSessionHTTPServer(); this.startTime = new Date(); - + if (options.logLevel) { process.env.LOG_LEVEL = options.logLevel; } @@ -163,7 +97,7 @@ export class N8NMCPEngine { total: Math.round(memoryUsage.heapTotal / 1024 / 1024), unit: 'MB' }, - version: '2.19.4' + version: '2.3.2' }; } catch (error) { logger.error('Health check failed:', error); @@ -172,7 +106,7 @@ export class N8NMCPEngine { uptime: 0, sessionActive: false, memoryUsage: { used: 0, total: 0, unit: 'MB' }, - version: '2.19.4' + version: '2.3.2' }; } } @@ -184,118 +118,10 @@ export class N8NMCPEngine { getSessionInfo(): { active: boolean; sessionId?: string; age?: number } { return this.server.getSessionInfo(); } - - /** - * Get all active session IDs (Phase 2 - REQ-5) - * Returns array of currently active session IDs - * - * @returns Array of session IDs - * @since 2.19.0 - * - * @example - * ```typescript - * const engine = new N8NMCPEngine(); - * const sessionIds = engine.getActiveSessions(); - * console.log(`Active sessions: ${sessionIds.length}`); - * ``` - */ - getActiveSessions(): string[] { - return this.server.getActiveSessions(); - } - - /** - * Get session state for a specific session (Phase 2 - REQ-5) - * Returns session state or null if session doesn't exist - * - * @param sessionId - The session ID to get state for - * @returns SessionState object or null - * @since 2.19.0 - * - * @example - * ```typescript - * const state = engine.getSessionState('session-123'); - * if (state) { - * // Save to database - * await db.saveSession(state); - * } - * ``` - */ - getSessionState(sessionId: string): SessionState | null { - return this.server.getSessionState(sessionId); - } - - /** - * Get all session states (Phase 2 - REQ-5) - * Returns array of all active session states for bulk backup - * - * @returns Array of SessionState objects - * @since 2.19.0 - * - * @example - * ```typescript - * // Periodic backup every 5 minutes - * setInterval(async () => { - * const states = engine.getAllSessionStates(); - * for (const state of states) { - * await database.upsertSession(state); - * } - * }, 300000); - * ``` - */ - getAllSessionStates(): SessionState[] { - return this.server.getAllSessionStates(); - } - - /** - * Manually restore a session (Phase 2 - REQ-5) - * Creates a session with the given ID and instance context - * - * @param sessionId - The session ID to restore - * @param instanceContext - Instance configuration - * @returns true if session was restored successfully, false otherwise - * @since 2.19.0 - * - * @example - * ```typescript - * // Restore session from database - * const session = await db.loadSession('session-123'); - * if (session) { - * const restored = engine.restoreSession( - * session.sessionId, - * session.instanceContext - * ); - * console.log(`Restored: ${restored}`); - * } - * ``` - */ - restoreSession(sessionId: string, instanceContext: InstanceContext): boolean { - return this.server.manuallyRestoreSession(sessionId, instanceContext); - } - - /** - * Manually delete a session (Phase 2 - REQ-5) - * Removes the session and cleans up resources - * - * @param sessionId - The session ID to delete - * @returns true if session was deleted, false if not found - * @since 2.19.0 - * - * @example - * ```typescript - * // Delete expired session - * const deleted = engine.deleteSession('session-123'); - * if (deleted) { - * await db.deleteSession('session-123'); - * } - * ``` - */ - deleteSession(sessionId: string): boolean { - return this.server.manuallyDeleteSession(sessionId); - } - + /** * Graceful shutdown for service lifecycle - * + * * @example * process.on('SIGTERM', async () => { * await engine.shutdown(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1408e1f..fc4d67b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -267,13 +267,6 @@ export class N8NDocumentationMCPServer { private dbHealthChecked: boolean = false; private async validateDatabaseHealth(): Promise { - // CRITICAL: Skip all database validation in test mode - // This allows session lifecycle tests to use empty :memory: databases - if (process.env.NODE_ENV === 'test') { - logger.debug('Skipping database validation in test mode'); - return; - } - if (!this.db) return; try { @@ -285,26 +278,18 @@ export class N8NDocumentationMCPServer { throw new Error('Database is empty. Run "npm run rebuild" to populate node data.'); } - // Check FTS5 support before attempting FTS5 queries - // sql.js doesn't support FTS5, so we need to skip FTS5 validation for sql.js databases - const hasFTS5 = this.db.checkFTS5Support(); + // Check if FTS5 table exists + const ftsExists = this.db.prepare(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='nodes_fts' + `).get(); - if (!hasFTS5) { - logger.warn('FTS5 not supported (likely using sql.js) - search will use basic queries'); + if (!ftsExists) { + logger.warn('FTS5 table missing - search performance will be degraded. Please run: npm run rebuild'); } else { - // Only check FTS5 table if FTS5 is supported - const ftsExists = this.db.prepare(` - SELECT name FROM sqlite_master - WHERE type='table' AND name='nodes_fts' - `).get(); - - if (!ftsExists) { - logger.warn('FTS5 table missing - search performance will be degraded. Please run: npm run rebuild'); - } else { - const ftsCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes_fts').get() as { count: number }; - if (ftsCount.count === 0) { - logger.warn('FTS5 index is empty - search will not work properly. Please run: npm run rebuild'); - } + const ftsCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes_fts').get() as { count: number }; + if (ftsCount.count === 0) { + logger.warn('FTS5 index is empty - search will not work properly. Please run: npm run rebuild'); } } diff --git a/src/types/session-restoration.ts b/src/types/session-restoration.ts deleted file mode 100644 index 318ccb2..0000000 --- a/src/types/session-restoration.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Session Restoration Types - * - * Defines types for session persistence and restoration functionality. - * Enables multi-tenant backends to restore sessions after container restarts. - * - * @since 2.19.0 - */ - -import { InstanceContext } from './instance-context'; - -/** - * Session restoration hook callback - * - * Called when a client tries to use an unknown session ID. - * The backend can load session state from external storage (database, Redis, etc.) - * and return the instance context to recreate the session. - * - * @param sessionId - The session ID that was not found in memory - * @returns Instance context to restore the session, or null if session should not be restored - * - * @example - * ```typescript - * const engine = new N8NMCPEngine({ - * onSessionNotFound: async (sessionId) => { - * // Load from database - * const session = await db.loadSession(sessionId); - * if (!session || session.expired) return null; - * return session.instanceContext; - * } - * }); - * ``` - */ -export type SessionRestoreHook = (sessionId: string) => Promise; - -/** - * Session restoration configuration options - * - * @since 2.19.0 - */ -export interface SessionRestorationOptions { - /** - * Session timeout in milliseconds - * After this period of inactivity, sessions are expired and cleaned up - * @default 1800000 (30 minutes) - */ - sessionTimeout?: number; - - /** - * Maximum time to wait for session restoration hook to complete - * If the hook takes longer than this, the request will fail with 408 Request Timeout - * @default 5000 (5 seconds) - */ - sessionRestorationTimeout?: number; - - /** - * Hook called when a client tries to use an unknown session ID - * Return instance context to restore the session, or null to reject - * - * @param sessionId - The session ID that was not found - * @returns Instance context for restoration, or null - * - * Error handling: - * - Hook throws exception โ†’ 500 Internal Server Error - * - Hook times out โ†’ 408 Request Timeout - * - Hook returns null โ†’ 400 Bad Request (session not found) - * - Hook returns invalid context โ†’ 400 Bad Request (invalid context) - */ - onSessionNotFound?: SessionRestoreHook; - - /** - * Number of retry attempts for failed session restoration - * - * When the restoration hook throws an error, the system will retry - * up to this many times with a delay between attempts. - * - * Timeout errors are NOT retried (already took too long). - * - * Note: The overall timeout (sessionRestorationTimeout) applies to - * ALL retry attempts combined, not per attempt. - * - * @default 0 (no retries) - * @example - * ```typescript - * const engine = new N8NMCPEngine({ - * onSessionNotFound: async (id) => db.loadSession(id), - * sessionRestorationRetries: 2, // Retry up to 2 times - * sessionRestorationRetryDelay: 100 // 100ms between retries - * }); - * ``` - * @since 2.19.0 - */ - sessionRestorationRetries?: number; - - /** - * Delay between retry attempts in milliseconds - * - * @default 100 (100 milliseconds) - * @since 2.19.0 - */ - sessionRestorationRetryDelay?: number; -} - -/** - * Session state for persistence - * Contains all information needed to restore a session after restart - * - * @since 2.19.0 - */ -export interface SessionState { - /** - * Unique session identifier - */ - sessionId: string; - - /** - * Instance-specific configuration - * Contains n8n API credentials and instance ID - */ - instanceContext: InstanceContext; - - /** - * When the session was created - */ - createdAt: Date; - - /** - * Last time the session was accessed - * Used for TTL-based expiration - */ - lastAccess: Date; - - /** - * When the session will expire - * Calculated from lastAccess + sessionTimeout - */ - expiresAt: Date; - - /** - * Optional metadata for application-specific use - */ - metadata?: Record; -} - -/** - * Session lifecycle event handlers - * - * These callbacks are called at various points in the session lifecycle. - * All callbacks are optional and should not throw errors. - * - * โš ๏ธ Performance Note: onSessionAccessed is called on EVERY request. - * Consider implementing throttling if you need database updates. - * - * @example - * ```typescript - * import throttle from 'lodash.throttle'; - * - * const engine = new N8NMCPEngine({ - * sessionEvents: { - * onSessionCreated: async (sessionId, context) => { - * await db.saveSession(sessionId, context); - * }, - * onSessionAccessed: throttle(async (sessionId) => { - * await db.updateLastAccess(sessionId); - * }, 60000) // Max once per minute per session - * } - * }); - * ``` - * - * @since 2.19.0 - */ -export interface SessionLifecycleEvents { - /** - * Called when a new session is created (not restored) - * - * Use cases: - * - Save session to database for persistence - * - Track session creation metrics - * - Initialize session-specific resources - * - * @param sessionId - The newly created session ID - * @param instanceContext - The instance context for this session - */ - onSessionCreated?: (sessionId: string, instanceContext: InstanceContext) => void | Promise; - - /** - * Called when a session is restored from external storage - * - * Use cases: - * - Track session restoration metrics - * - Log successful recovery after restart - * - Update database restoration timestamp - * - * @param sessionId - The restored session ID - * @param instanceContext - The restored instance context - */ - onSessionRestored?: (sessionId: string, instanceContext: InstanceContext) => void | Promise; - - /** - * Called on EVERY request that uses an existing session - * - * โš ๏ธ HIGH FREQUENCY: This event fires for every MCP tool call. - * For a busy session, this could be 100+ calls per minute. - * - * Recommended: Implement throttling if you need database updates - * - * Use cases: - * - Update session last_access timestamp (throttled) - * - Track session activity metrics - * - Extend session TTL in database - * - * @param sessionId - The session ID that was accessed - */ - onSessionAccessed?: (sessionId: string) => void | Promise; - - /** - * Called when a session expires due to inactivity - * - * Called during cleanup cycle (every 5 minutes) BEFORE session removal. - * This allows you to perform cleanup operations before the session is gone. - * - * Use cases: - * - Delete session from database - * - Log session expiration metrics - * - Cleanup session-specific resources - * - * @param sessionId - The session ID that expired - */ - onSessionExpired?: (sessionId: string) => void | Promise; - - /** - * Called when a session is manually deleted - * - * Use cases: - * - Delete session from database - * - Cascade delete related data - * - Log manual session termination - * - * @param sessionId - The session ID that was deleted - */ - onSessionDeleted?: (sessionId: string) => void | Promise; -} diff --git a/supabase-telemetry-aggregation.sql b/supabase-telemetry-aggregation.sql deleted file mode 100644 index 32237d9..0000000 --- a/supabase-telemetry-aggregation.sql +++ /dev/null @@ -1,752 +0,0 @@ --- ============================================================================ --- N8N-MCP Telemetry Aggregation & Automated Pruning System --- ============================================================================ --- Purpose: Create aggregation tables and automated cleanup to maintain --- database under 500MB free tier limit while preserving insights --- --- Strategy: Aggregate โ†’ Delete โ†’ Retain only recent raw events --- Expected savings: ~120 MB (from 265 MB โ†’ ~145 MB steady state) --- ============================================================================ - --- ============================================================================ --- PART 1: AGGREGATION TABLES --- ============================================================================ - --- Daily tool usage summary (replaces 96 MB of tool_sequence raw data) -CREATE TABLE IF NOT EXISTS telemetry_tool_usage_daily ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - aggregation_date DATE NOT NULL, - user_id TEXT NOT NULL, - tool_name TEXT NOT NULL, - usage_count INTEGER NOT NULL DEFAULT 0, - success_count INTEGER NOT NULL DEFAULT 0, - error_count INTEGER NOT NULL DEFAULT 0, - avg_execution_time_ms NUMERIC, - total_execution_time_ms BIGINT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(aggregation_date, user_id, tool_name) -); - -CREATE INDEX idx_tool_usage_daily_date ON telemetry_tool_usage_daily(aggregation_date DESC); -CREATE INDEX idx_tool_usage_daily_tool ON telemetry_tool_usage_daily(tool_name); -CREATE INDEX idx_tool_usage_daily_user ON telemetry_tool_usage_daily(user_id); - -COMMENT ON TABLE telemetry_tool_usage_daily IS 'Daily aggregation of tool usage replacing raw tool_used and tool_sequence events. Saves ~95% storage.'; - --- Tool sequence patterns (replaces individual sequences with pattern analysis) -CREATE TABLE IF NOT EXISTS telemetry_tool_patterns ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - aggregation_date DATE NOT NULL, - tool_sequence TEXT[] NOT NULL, -- Array of tool names in order - sequence_hash TEXT NOT NULL, -- Hash of the sequence for grouping - occurrence_count INTEGER NOT NULL DEFAULT 1, - avg_sequence_duration_ms NUMERIC, - success_rate NUMERIC, -- 0.0 to 1.0 - common_errors JSONB, -- {"error_type": count} - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(aggregation_date, sequence_hash) -); - -CREATE INDEX idx_tool_patterns_date ON telemetry_tool_patterns(aggregation_date DESC); -CREATE INDEX idx_tool_patterns_hash ON telemetry_tool_patterns(sequence_hash); - -COMMENT ON TABLE telemetry_tool_patterns IS 'Common tool usage patterns aggregated daily. Identifies workflows and AI behavior patterns.'; - --- Workflow insights (aggregates workflow_created events) -CREATE TABLE IF NOT EXISTS telemetry_workflow_insights ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - aggregation_date DATE NOT NULL, - complexity TEXT, -- simple/medium/complex - node_count_range TEXT, -- 1-5, 6-10, 11-20, 21+ - has_trigger BOOLEAN, - has_webhook BOOLEAN, - common_node_types TEXT[], -- Top node types used - workflow_count INTEGER NOT NULL DEFAULT 0, - avg_node_count NUMERIC, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(aggregation_date, complexity, node_count_range, has_trigger, has_webhook) -); - -CREATE INDEX idx_workflow_insights_date ON telemetry_workflow_insights(aggregation_date DESC); -CREATE INDEX idx_workflow_insights_complexity ON telemetry_workflow_insights(complexity); - -COMMENT ON TABLE telemetry_workflow_insights IS 'Daily workflow creation patterns. Shows adoption trends without storing duplicate workflows.'; - --- Error patterns (keeps error intelligence, deletes raw error events) -CREATE TABLE IF NOT EXISTS telemetry_error_patterns ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - aggregation_date DATE NOT NULL, - error_type TEXT NOT NULL, - error_context TEXT, -- e.g., 'validation', 'workflow_execution', 'node_operation' - occurrence_count INTEGER NOT NULL DEFAULT 1, - affected_users INTEGER NOT NULL DEFAULT 0, - first_seen TIMESTAMPTZ, - last_seen TIMESTAMPTZ, - sample_error_message TEXT, -- Keep one representative message - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(aggregation_date, error_type, error_context) -); - -CREATE INDEX idx_error_patterns_date ON telemetry_error_patterns(aggregation_date DESC); -CREATE INDEX idx_error_patterns_type ON telemetry_error_patterns(error_type); - -COMMENT ON TABLE telemetry_error_patterns IS 'Error patterns over time. Preserves debugging insights while pruning raw error events.'; - --- Validation insights (aggregates validation_details) -CREATE TABLE IF NOT EXISTS telemetry_validation_insights ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - aggregation_date DATE NOT NULL, - validation_type TEXT, -- 'node', 'workflow', 'expression' - profile TEXT, -- 'minimal', 'runtime', 'ai-friendly', 'strict' - success_count INTEGER NOT NULL DEFAULT 0, - failure_count INTEGER NOT NULL DEFAULT 0, - common_failure_reasons JSONB, -- {"reason": count} - avg_validation_time_ms NUMERIC, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(aggregation_date, validation_type, profile) -); - -CREATE INDEX idx_validation_insights_date ON telemetry_validation_insights(aggregation_date DESC); -CREATE INDEX idx_validation_insights_type ON telemetry_validation_insights(validation_type); - -COMMENT ON TABLE telemetry_validation_insights IS 'Validation success/failure patterns. Shows where users struggle without storing every validation event.'; - --- ============================================================================ --- PART 2: AGGREGATION FUNCTIONS --- ============================================================================ - --- Function to aggregate tool usage data -CREATE OR REPLACE FUNCTION aggregate_tool_usage(cutoff_date TIMESTAMPTZ) -RETURNS INTEGER AS $$ -DECLARE - rows_aggregated INTEGER; -BEGIN - -- Aggregate tool_used events - INSERT INTO telemetry_tool_usage_daily ( - aggregation_date, - user_id, - tool_name, - usage_count, - success_count, - error_count, - avg_execution_time_ms, - total_execution_time_ms - ) - SELECT - DATE(created_at) as aggregation_date, - user_id, - properties->>'toolName' as tool_name, - COUNT(*) as usage_count, - COUNT(*) FILTER (WHERE (properties->>'success')::boolean = true) as success_count, - COUNT(*) FILTER (WHERE (properties->>'success')::boolean = false OR properties->>'error' IS NOT NULL) as error_count, - AVG((properties->>'executionTime')::numeric) as avg_execution_time_ms, - SUM((properties->>'executionTime')::numeric) as total_execution_time_ms - FROM telemetry_events - WHERE event = 'tool_used' - AND created_at < cutoff_date - AND properties->>'toolName' IS NOT NULL - GROUP BY DATE(created_at), user_id, properties->>'toolName' - ON CONFLICT (aggregation_date, user_id, tool_name) - DO UPDATE SET - usage_count = telemetry_tool_usage_daily.usage_count + EXCLUDED.usage_count, - success_count = telemetry_tool_usage_daily.success_count + EXCLUDED.success_count, - error_count = telemetry_tool_usage_daily.error_count + EXCLUDED.error_count, - total_execution_time_ms = telemetry_tool_usage_daily.total_execution_time_ms + EXCLUDED.total_execution_time_ms, - avg_execution_time_ms = (telemetry_tool_usage_daily.total_execution_time_ms + EXCLUDED.total_execution_time_ms) / - (telemetry_tool_usage_daily.usage_count + EXCLUDED.usage_count), - updated_at = NOW(); - - GET DIAGNOSTICS rows_aggregated = ROW_COUNT; - - RAISE NOTICE 'Aggregated % rows from tool_used events', rows_aggregated; - RETURN rows_aggregated; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION aggregate_tool_usage IS 'Aggregates tool_used events into daily summaries before deletion'; - --- Function to aggregate tool sequence patterns -CREATE OR REPLACE FUNCTION aggregate_tool_patterns(cutoff_date TIMESTAMPTZ) -RETURNS INTEGER AS $$ -DECLARE - rows_aggregated INTEGER; -BEGIN - INSERT INTO telemetry_tool_patterns ( - aggregation_date, - tool_sequence, - sequence_hash, - occurrence_count, - avg_sequence_duration_ms, - success_rate - ) - SELECT - DATE(created_at) as aggregation_date, - (properties->>'toolSequence')::text[] as tool_sequence, - md5(array_to_string((properties->>'toolSequence')::text[], ',')) as sequence_hash, - COUNT(*) as occurrence_count, - AVG((properties->>'duration')::numeric) as avg_sequence_duration_ms, - AVG(CASE WHEN (properties->>'success')::boolean THEN 1.0 ELSE 0.0 END) as success_rate - FROM telemetry_events - WHERE event = 'tool_sequence' - AND created_at < cutoff_date - AND properties->>'toolSequence' IS NOT NULL - GROUP BY DATE(created_at), (properties->>'toolSequence')::text[] - ON CONFLICT (aggregation_date, sequence_hash) - DO UPDATE SET - occurrence_count = telemetry_tool_patterns.occurrence_count + EXCLUDED.occurrence_count, - avg_sequence_duration_ms = ( - (telemetry_tool_patterns.avg_sequence_duration_ms * telemetry_tool_patterns.occurrence_count + - EXCLUDED.avg_sequence_duration_ms * EXCLUDED.occurrence_count) / - (telemetry_tool_patterns.occurrence_count + EXCLUDED.occurrence_count) - ), - success_rate = ( - (telemetry_tool_patterns.success_rate * telemetry_tool_patterns.occurrence_count + - EXCLUDED.success_rate * EXCLUDED.occurrence_count) / - (telemetry_tool_patterns.occurrence_count + EXCLUDED.occurrence_count) - ), - updated_at = NOW(); - - GET DIAGNOSTICS rows_aggregated = ROW_COUNT; - - RAISE NOTICE 'Aggregated % rows from tool_sequence events', rows_aggregated; - RETURN rows_aggregated; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION aggregate_tool_patterns IS 'Aggregates tool_sequence events into pattern analysis before deletion'; - --- Function to aggregate workflow insights -CREATE OR REPLACE FUNCTION aggregate_workflow_insights(cutoff_date TIMESTAMPTZ) -RETURNS INTEGER AS $$ -DECLARE - rows_aggregated INTEGER; -BEGIN - INSERT INTO telemetry_workflow_insights ( - aggregation_date, - complexity, - node_count_range, - has_trigger, - has_webhook, - common_node_types, - workflow_count, - avg_node_count - ) - SELECT - DATE(created_at) as aggregation_date, - properties->>'complexity' as complexity, - CASE - WHEN (properties->>'nodeCount')::int BETWEEN 1 AND 5 THEN '1-5' - WHEN (properties->>'nodeCount')::int BETWEEN 6 AND 10 THEN '6-10' - WHEN (properties->>'nodeCount')::int BETWEEN 11 AND 20 THEN '11-20' - ELSE '21+' - END as node_count_range, - (properties->>'hasTrigger')::boolean as has_trigger, - (properties->>'hasWebhook')::boolean as has_webhook, - ARRAY[]::text[] as common_node_types, -- Will be populated separately if needed - COUNT(*) as workflow_count, - AVG((properties->>'nodeCount')::numeric) as avg_node_count - FROM telemetry_events - WHERE event = 'workflow_created' - AND created_at < cutoff_date - GROUP BY - DATE(created_at), - properties->>'complexity', - node_count_range, - (properties->>'hasTrigger')::boolean, - (properties->>'hasWebhook')::boolean - ON CONFLICT (aggregation_date, complexity, node_count_range, has_trigger, has_webhook) - DO UPDATE SET - workflow_count = telemetry_workflow_insights.workflow_count + EXCLUDED.workflow_count, - avg_node_count = ( - (telemetry_workflow_insights.avg_node_count * telemetry_workflow_insights.workflow_count + - EXCLUDED.avg_node_count * EXCLUDED.workflow_count) / - (telemetry_workflow_insights.workflow_count + EXCLUDED.workflow_count) - ), - updated_at = NOW(); - - GET DIAGNOSTICS rows_aggregated = ROW_COUNT; - - RAISE NOTICE 'Aggregated % rows from workflow_created events', rows_aggregated; - RETURN rows_aggregated; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION aggregate_workflow_insights IS 'Aggregates workflow_created events into pattern insights before deletion'; - --- Function to aggregate error patterns -CREATE OR REPLACE FUNCTION aggregate_error_patterns(cutoff_date TIMESTAMPTZ) -RETURNS INTEGER AS $$ -DECLARE - rows_aggregated INTEGER; -BEGIN - INSERT INTO telemetry_error_patterns ( - aggregation_date, - error_type, - error_context, - occurrence_count, - affected_users, - first_seen, - last_seen, - sample_error_message - ) - SELECT - DATE(created_at) as aggregation_date, - properties->>'errorType' as error_type, - properties->>'context' as error_context, - COUNT(*) as occurrence_count, - COUNT(DISTINCT user_id) as affected_users, - MIN(created_at) as first_seen, - MAX(created_at) as last_seen, - (ARRAY_AGG(properties->>'message' ORDER BY created_at DESC))[1] as sample_error_message - FROM telemetry_events - WHERE event = 'error_occurred' - AND created_at < cutoff_date - GROUP BY DATE(created_at), properties->>'errorType', properties->>'context' - ON CONFLICT (aggregation_date, error_type, error_context) - DO UPDATE SET - occurrence_count = telemetry_error_patterns.occurrence_count + EXCLUDED.occurrence_count, - affected_users = GREATEST(telemetry_error_patterns.affected_users, EXCLUDED.affected_users), - first_seen = LEAST(telemetry_error_patterns.first_seen, EXCLUDED.first_seen), - last_seen = GREATEST(telemetry_error_patterns.last_seen, EXCLUDED.last_seen), - updated_at = NOW(); - - GET DIAGNOSTICS rows_aggregated = ROW_COUNT; - - RAISE NOTICE 'Aggregated % rows from error_occurred events', rows_aggregated; - RETURN rows_aggregated; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION aggregate_error_patterns IS 'Aggregates error_occurred events into pattern analysis before deletion'; - --- Function to aggregate validation insights -CREATE OR REPLACE FUNCTION aggregate_validation_insights(cutoff_date TIMESTAMPTZ) -RETURNS INTEGER AS $$ -DECLARE - rows_aggregated INTEGER; -BEGIN - INSERT INTO telemetry_validation_insights ( - aggregation_date, - validation_type, - profile, - success_count, - failure_count, - common_failure_reasons, - avg_validation_time_ms - ) - SELECT - DATE(created_at) as aggregation_date, - properties->>'validationType' as validation_type, - properties->>'profile' as profile, - COUNT(*) FILTER (WHERE (properties->>'success')::boolean = true) as success_count, - COUNT(*) FILTER (WHERE (properties->>'success')::boolean = false) as failure_count, - jsonb_object_agg( - COALESCE(properties->>'failureReason', 'unknown'), - COUNT(*) - ) FILTER (WHERE (properties->>'success')::boolean = false) as common_failure_reasons, - AVG((properties->>'validationTime')::numeric) as avg_validation_time_ms - FROM telemetry_events - WHERE event = 'validation_details' - AND created_at < cutoff_date - GROUP BY DATE(created_at), properties->>'validationType', properties->>'profile' - ON CONFLICT (aggregation_date, validation_type, profile) - DO UPDATE SET - success_count = telemetry_validation_insights.success_count + EXCLUDED.success_count, - failure_count = telemetry_validation_insights.failure_count + EXCLUDED.failure_count, - updated_at = NOW(); - - GET DIAGNOSTICS rows_aggregated = ROW_COUNT; - - RAISE NOTICE 'Aggregated % rows from validation_details events', rows_aggregated; - RETURN rows_aggregated; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION aggregate_validation_insights IS 'Aggregates validation_details events into insights before deletion'; - --- ============================================================================ --- PART 3: MASTER AGGREGATION & CLEANUP FUNCTION --- ============================================================================ - -CREATE OR REPLACE FUNCTION run_telemetry_aggregation_and_cleanup( - retention_days INTEGER DEFAULT 3 -) -RETURNS TABLE( - event_type TEXT, - rows_aggregated INTEGER, - rows_deleted INTEGER, - space_freed_mb NUMERIC -) AS $$ -DECLARE - cutoff_date TIMESTAMPTZ; - total_before BIGINT; - total_after BIGINT; - agg_count INTEGER; - del_count INTEGER; -BEGIN - cutoff_date := NOW() - (retention_days || ' days')::INTERVAL; - - RAISE NOTICE 'Starting aggregation and cleanup for data older than %', cutoff_date; - - -- Get table size before cleanup - SELECT pg_total_relation_size('telemetry_events') INTO total_before; - - -- ======================================================================== - -- STEP 1: AGGREGATE DATA BEFORE DELETION - -- ======================================================================== - - -- Tool usage aggregation - SELECT aggregate_tool_usage(cutoff_date) INTO agg_count; - SELECT COUNT(*) INTO del_count FROM telemetry_events - WHERE event = 'tool_used' AND created_at < cutoff_date; - - event_type := 'tool_used'; - rows_aggregated := agg_count; - rows_deleted := del_count; - RETURN NEXT; - - -- Tool patterns aggregation - SELECT aggregate_tool_patterns(cutoff_date) INTO agg_count; - SELECT COUNT(*) INTO del_count FROM telemetry_events - WHERE event = 'tool_sequence' AND created_at < cutoff_date; - - event_type := 'tool_sequence'; - rows_aggregated := agg_count; - rows_deleted := del_count; - RETURN NEXT; - - -- Workflow insights aggregation - SELECT aggregate_workflow_insights(cutoff_date) INTO agg_count; - SELECT COUNT(*) INTO del_count FROM telemetry_events - WHERE event = 'workflow_created' AND created_at < cutoff_date; - - event_type := 'workflow_created'; - rows_aggregated := agg_count; - rows_deleted := del_count; - RETURN NEXT; - - -- Error patterns aggregation - SELECT aggregate_error_patterns(cutoff_date) INTO agg_count; - SELECT COUNT(*) INTO del_count FROM telemetry_events - WHERE event = 'error_occurred' AND created_at < cutoff_date; - - event_type := 'error_occurred'; - rows_aggregated := agg_count; - rows_deleted := del_count; - RETURN NEXT; - - -- Validation insights aggregation - SELECT aggregate_validation_insights(cutoff_date) INTO agg_count; - SELECT COUNT(*) INTO del_count FROM telemetry_events - WHERE event = 'validation_details' AND created_at < cutoff_date; - - event_type := 'validation_details'; - rows_aggregated := agg_count; - rows_deleted := del_count; - RETURN NEXT; - - -- ======================================================================== - -- STEP 2: DELETE OLD RAW EVENTS (now that they're aggregated) - -- ======================================================================== - - DELETE FROM telemetry_events - WHERE created_at < cutoff_date - AND event IN ( - 'tool_used', - 'tool_sequence', - 'workflow_created', - 'validation_details', - 'session_start', - 'search_query', - 'diagnostic_completed', - 'health_check_completed' - ); - - -- Keep error_occurred for 30 days (extended retention for debugging) - DELETE FROM telemetry_events - WHERE created_at < (NOW() - INTERVAL '30 days') - AND event = 'error_occurred'; - - -- ======================================================================== - -- STEP 3: CLEAN UP OLD WORKFLOWS (keep only unique patterns) - -- ======================================================================== - - -- Delete duplicate workflows older than retention period - WITH workflow_duplicates AS ( - SELECT id - FROM ( - SELECT id, - ROW_NUMBER() OVER ( - PARTITION BY workflow_hash - ORDER BY created_at DESC - ) as rn - FROM telemetry_workflows - WHERE created_at < cutoff_date - ) sub - WHERE rn > 1 - ) - DELETE FROM telemetry_workflows - WHERE id IN (SELECT id FROM workflow_duplicates); - - GET DIAGNOSTICS del_count = ROW_COUNT; - - event_type := 'duplicate_workflows'; - rows_aggregated := 0; - rows_deleted := del_count; - RETURN NEXT; - - -- ======================================================================== - -- STEP 4: VACUUM TO RECLAIM SPACE - -- ======================================================================== - - -- Note: VACUUM cannot be run inside a function, must be run separately - -- The cron job will handle this - - -- Get table size after cleanup - SELECT pg_total_relation_size('telemetry_events') INTO total_after; - - -- Summary row - event_type := 'TOTAL_SPACE_FREED'; - rows_aggregated := 0; - rows_deleted := 0; - space_freed_mb := ROUND((total_before - total_after)::NUMERIC / 1024 / 1024, 2); - RETURN NEXT; - - RAISE NOTICE 'Cleanup complete. Space freed: % MB', space_freed_mb; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION run_telemetry_aggregation_and_cleanup IS 'Master function to aggregate data and delete old events. Run daily via cron.'; - --- ============================================================================ --- PART 4: SUPABASE CRON JOB SETUP --- ============================================================================ - --- Enable pg_cron extension (if not already enabled) -CREATE EXTENSION IF NOT EXISTS pg_cron; - --- Schedule daily cleanup at 2 AM UTC (low traffic time) --- This will aggregate data older than 3 days and then delete it -SELECT cron.schedule( - 'telemetry-daily-cleanup', - '0 2 * * *', -- Every day at 2 AM UTC - $$ - SELECT run_telemetry_aggregation_and_cleanup(3); - VACUUM ANALYZE telemetry_events; - VACUUM ANALYZE telemetry_workflows; - $$ -); - -COMMENT ON EXTENSION pg_cron IS 'Cron job scheduler for automated telemetry cleanup'; - --- ============================================================================ --- PART 5: MONITORING & ALERTING --- ============================================================================ - --- Function to check database size and alert if approaching limit -CREATE OR REPLACE FUNCTION check_database_size() -RETURNS TABLE( - total_size_mb NUMERIC, - events_size_mb NUMERIC, - workflows_size_mb NUMERIC, - aggregates_size_mb NUMERIC, - percent_of_limit NUMERIC, - days_until_full NUMERIC, - status TEXT -) AS $$ -DECLARE - db_size BIGINT; - events_size BIGINT; - workflows_size BIGINT; - agg_size BIGINT; - limit_mb CONSTANT NUMERIC := 500; -- Free tier limit - growth_rate_mb_per_day NUMERIC; -BEGIN - -- Get current sizes - SELECT pg_database_size(current_database()) INTO db_size; - SELECT pg_total_relation_size('telemetry_events') INTO events_size; - SELECT pg_total_relation_size('telemetry_workflows') INTO workflows_size; - - SELECT COALESCE( - pg_total_relation_size('telemetry_tool_usage_daily') + - pg_total_relation_size('telemetry_tool_patterns') + - pg_total_relation_size('telemetry_workflow_insights') + - pg_total_relation_size('telemetry_error_patterns') + - pg_total_relation_size('telemetry_validation_insights'), - 0 - ) INTO agg_size; - - total_size_mb := ROUND(db_size::NUMERIC / 1024 / 1024, 2); - events_size_mb := ROUND(events_size::NUMERIC / 1024 / 1024, 2); - workflows_size_mb := ROUND(workflows_size::NUMERIC / 1024 / 1024, 2); - aggregates_size_mb := ROUND(agg_size::NUMERIC / 1024 / 1024, 2); - percent_of_limit := ROUND((total_size_mb / limit_mb) * 100, 1); - - -- Estimate growth rate (simple 7-day average) - SELECT ROUND( - (SELECT COUNT(*) FROM telemetry_events WHERE created_at > NOW() - INTERVAL '7 days')::NUMERIC - * (pg_column_size(telemetry_events.*))::NUMERIC - / 7 / 1024 / 1024, 2 - ) INTO growth_rate_mb_per_day - FROM telemetry_events LIMIT 1; - - IF growth_rate_mb_per_day > 0 THEN - days_until_full := ROUND((limit_mb - total_size_mb) / growth_rate_mb_per_day, 0); - ELSE - days_until_full := NULL; - END IF; - - -- Determine status - IF percent_of_limit >= 90 THEN - status := 'CRITICAL - Immediate action required'; - ELSIF percent_of_limit >= 75 THEN - status := 'WARNING - Monitor closely'; - ELSIF percent_of_limit >= 50 THEN - status := 'CAUTION - Plan optimization'; - ELSE - status := 'HEALTHY'; - END IF; - - RETURN NEXT; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION check_database_size IS 'Monitor database size and growth. Run daily or on-demand.'; - --- ============================================================================ --- PART 6: EMERGENCY CLEANUP (ONE-TIME USE) --- ============================================================================ - --- Emergency function to immediately free up space (use if critical) -CREATE OR REPLACE FUNCTION emergency_cleanup() -RETURNS TABLE( - action TEXT, - rows_deleted INTEGER, - space_freed_mb NUMERIC -) AS $$ -DECLARE - size_before BIGINT; - size_after BIGINT; - del_count INTEGER; -BEGIN - SELECT pg_total_relation_size('telemetry_events') INTO size_before; - - -- Aggregate everything older than 7 days - PERFORM run_telemetry_aggregation_and_cleanup(7); - - -- Delete all non-critical events older than 7 days - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '7 days' - AND event NOT IN ('error_occurred', 'workflow_validation_failed'); - - GET DIAGNOSTICS del_count = ROW_COUNT; - - action := 'Deleted non-critical events > 7 days'; - rows_deleted := del_count; - RETURN NEXT; - - -- Delete error events older than 14 days - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '14 days' - AND event = 'error_occurred'; - - GET DIAGNOSTICS del_count = ROW_COUNT; - - action := 'Deleted error events > 14 days'; - rows_deleted := del_count; - RETURN NEXT; - - -- Delete duplicate workflows - WITH workflow_duplicates AS ( - SELECT id - FROM ( - SELECT id, - ROW_NUMBER() OVER ( - PARTITION BY workflow_hash - ORDER BY created_at DESC - ) as rn - FROM telemetry_workflows - ) sub - WHERE rn > 1 - ) - DELETE FROM telemetry_workflows - WHERE id IN (SELECT id FROM workflow_duplicates); - - GET DIAGNOSTICS del_count = ROW_COUNT; - - action := 'Deleted duplicate workflows'; - rows_deleted := del_count; - RETURN NEXT; - - -- VACUUM will be run separately - SELECT pg_total_relation_size('telemetry_events') INTO size_after; - - action := 'TOTAL (run VACUUM separately)'; - rows_deleted := 0; - space_freed_mb := ROUND((size_before - size_after)::NUMERIC / 1024 / 1024, 2); - RETURN NEXT; - - RAISE NOTICE 'Emergency cleanup complete. Run VACUUM FULL for maximum space recovery.'; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION emergency_cleanup IS 'Emergency cleanup when database is near capacity. Run once, then VACUUM.'; - --- ============================================================================ --- USAGE INSTRUCTIONS --- ============================================================================ - -/* - -SETUP (Run once): - 1. Execute this entire script in Supabase SQL Editor - 2. Verify cron job is scheduled: - SELECT * FROM cron.job; - 3. Run initial monitoring: - SELECT * FROM check_database_size(); - -DAILY OPERATIONS (Automatic): - - Cron job runs daily at 2 AM UTC - - Aggregates data older than 3 days - - Deletes raw events after aggregation - - Vacuums tables to reclaim space - -MONITORING: - -- Check current database health - SELECT * FROM check_database_size(); - - -- View aggregated insights - SELECT * FROM telemetry_tool_usage_daily ORDER BY aggregation_date DESC LIMIT 100; - SELECT * FROM telemetry_tool_patterns ORDER BY occurrence_count DESC LIMIT 20; - SELECT * FROM telemetry_error_patterns ORDER BY occurrence_count DESC LIMIT 20; - -MANUAL CLEANUP (if needed): - -- Run cleanup manually (3-day retention) - SELECT * FROM run_telemetry_aggregation_and_cleanup(3); - VACUUM ANALYZE telemetry_events; - - -- Emergency cleanup (7-day retention) - SELECT * FROM emergency_cleanup(); - VACUUM FULL telemetry_events; - VACUUM FULL telemetry_workflows; - -TUNING: - -- Adjust retention period (e.g., 5 days instead of 3) - SELECT cron.schedule( - 'telemetry-daily-cleanup', - '0 2 * * *', - $$ SELECT run_telemetry_aggregation_and_cleanup(5); VACUUM ANALYZE telemetry_events; $$ - ); - -EXPECTED RESULTS: - - Initial run: ~120 MB space freed (265 MB โ†’ ~145 MB) - - Steady state: ~90-120 MB total database size - - Growth rate: ~2-3 MB/day (down from 7.7 MB/day) - - Headroom: 70-80% of free tier limit available - -*/ diff --git a/telemetry-pruning-analysis.md b/telemetry-pruning-analysis.md deleted file mode 100644 index de093f4..0000000 --- a/telemetry-pruning-analysis.md +++ /dev/null @@ -1,961 +0,0 @@ -# n8n-MCP Telemetry Database Pruning Strategy - -**Analysis Date:** 2025-10-10 -**Current Database Size:** 265 MB (telemetry_events: 199 MB, telemetry_workflows: 66 MB) -**Free Tier Limit:** 500 MB -**Projected 4-Week Size:** 609 MB (exceeds limit by 109 MB) - ---- - -## Executive Summary - -**Critical Finding:** At current growth rate (56.75% of data from last 7 days), we will exceed the 500 MB free tier limit in approximately 2 weeks. Implementing a 7-day retention policy can immediately save 36.5 MB (37.6%) and prevent database overflow. - -**Key Insights:** -- 641,487 event records consuming 199 MB -- 17,247 workflow records consuming 66 MB -- Daily growth rate: ~7-8 MB/day for events -- 43.25% of data is older than 7 days but provides diminishing value - -**Immediate Action Required:** Implement automated pruning to maintain database under 500 MB. - ---- - -## 1. Current State Assessment - -### Database Size and Distribution - -| Table | Rows | Current Size | Growth Rate | Bytes/Row | -|-------|------|--------------|-------------|-----------| -| telemetry_events | 641,487 | 199 MB | 56.66% from last 7d | 325 | -| telemetry_workflows | 17,247 | 66 MB | 60.09% from last 7d | 4,013 | -| **TOTAL** | **658,734** | **265 MB** | **56.75% from last 7d** | **403** | - -### Event Type Distribution - -| Event Type | Count | % of Total | Storage | Avg Props Size | Oldest Event | -|------------|-------|-----------|---------|----------------|--------------| -| tool_sequence | 362,170 | 56.4% | 67 MB | 194 bytes | 2025-09-26 | -| tool_used | 191,659 | 29.9% | 14 MB | 77 bytes | 2025-09-26 | -| validation_details | 36,266 | 5.7% | 11 MB | 329 bytes | 2025-09-26 | -| workflow_created | 23,151 | 3.6% | 2.6 MB | 115 bytes | 2025-09-26 | -| session_start | 12,575 | 2.0% | 1.2 MB | 101 bytes | 2025-09-26 | -| workflow_validation_failed | 9,739 | 1.5% | 314 KB | 33 bytes | 2025-09-26 | -| error_occurred | 4,935 | 0.8% | 626 KB | 130 bytes | 2025-09-26 | -| search_query | 974 | 0.2% | 106 KB | 112 bytes | 2025-09-26 | -| Other | 18 | <0.1% | 5 KB | Various | Recent | - -### Growth Pattern Analysis - -**Daily Data Accumulation (Last 15 Days):** - -| Date | Events/Day | Daily Size | Cumulative Size | -|------|-----------|------------|-----------------| -| 2025-10-10 | 28,457 | 4.3 MB | 97 MB | -| 2025-10-09 | 54,717 | 8.2 MB | 93 MB | -| 2025-10-08 | 52,901 | 7.9 MB | 85 MB | -| 2025-10-07 | 52,538 | 8.1 MB | 77 MB | -| 2025-10-06 | 51,401 | 7.8 MB | 69 MB | -| 2025-10-05 | 50,528 | 7.9 MB | 61 MB | - -**Average Daily Growth:** ~7.7 MB/day -**Weekly Growth:** ~54 MB/week -**Projected to hit 500 MB limit:** ~17 days (late October 2025) - -### Workflow Data Distribution - -| Complexity | Count | % | Avg Nodes | Avg JSON Size | Estimated Size | -|-----------|-------|---|-----------|---------------|----------------| -| Simple | 12,923 | 77.6% | 5.48 | 2,122 bytes | 20 MB | -| Medium | 3,708 | 22.3% | 13.93 | 4,458 bytes | 12 MB | -| Complex | 616 | 0.1% | 26.62 | 7,909 bytes | 3.2 MB | - -**Key Finding:** No duplicate workflow hashes found - each workflow is unique (good data quality). - ---- - -## 2. Data Value Classification - -### TIER 1: Critical - Keep Indefinitely - -**Error Patterns (error_occurred)** -- **Why:** Essential for identifying systemic issues and regression detection -- **Volume:** 4,935 events (626 KB) -- **Recommendation:** Keep all errors with aggregated summaries for older data -- **Retention:** Detailed errors 30 days, aggregated stats indefinitely - -**Tool Usage Statistics (Aggregated)** -- **Why:** Product analytics and feature prioritization -- **Recommendation:** Aggregate daily/weekly summaries after 14 days -- **Keep:** Summary tables with tool usage counts, success rates, avg duration - -### TIER 2: High Value - Keep 30 Days - -**Validation Details (validation_details)** -- **Current:** 36,266 events, 11 MB, avg 329 bytes -- **Why:** Important for understanding validation issues during current development cycle -- **Value Period:** 30 days (covers current version development) -- **After 30d:** Aggregate to summary stats (validation success rate by node type) - -**Workflow Creation Patterns (workflow_created)** -- **Current:** 23,151 events, 2.6 MB -- **Why:** Track feature adoption and workflow patterns -- **Value Period:** 30 days for detailed analysis -- **After 30d:** Keep aggregated metrics only - -### TIER 3: Medium Value - Keep 14 Days - -**Session Data (session_start)** -- **Current:** 12,575 events, 1.2 MB -- **Why:** User engagement tracking -- **Value Period:** 14 days sufficient for engagement analysis -- **Pruning Impact:** 497 KB saved (40% reduction) - -**Workflow Validation Failures (workflow_validation_failed)** -- **Current:** 9,739 events, 314 KB -- **Why:** Tracks validation patterns but less detailed than validation_details -- **Value Period:** 14 days -- **Pruning Impact:** 170 KB saved (54% reduction) - -### TIER 4: Short-Term Value - Keep 7 Days - -**Tool Sequences (tool_sequence)** -- **Current:** 362,170 events, 67 MB (largest table!) -- **Why:** Tracks multi-tool workflows but extremely high volume -- **Value Period:** 7 days for recent pattern analysis -- **Pruning Impact:** 29 MB saved (43% reduction) - HIGHEST IMPACT -- **Rationale:** Tool usage patterns stabilize quickly; older sequences provide diminishing returns - -**Tool Usage Events (tool_used)** -- **Current:** 191,659 events, 14 MB -- **Why:** Individual tool executions - can be aggregated -- **Value Period:** 7 days detailed, then aggregate -- **Pruning Impact:** 6.2 MB saved (44% reduction) - -**Search Queries (search_query)** -- **Current:** 974 events, 106 KB -- **Why:** Low volume, useful for understanding search patterns -- **Value Period:** 7 days sufficient -- **Pruning Impact:** Minimal (~1 KB) - -### TIER 5: Ephemeral - Keep 3 Days - -**Diagnostic/Health Checks (diagnostic_completed, health_check_completed)** -- **Current:** 17 events, ~2.5 KB -- **Why:** Operational health checks, only current state matters -- **Value Period:** 3 days -- **Pruning Impact:** Negligible but good hygiene - -### Workflow Data Retention Strategy - -**telemetry_workflows Table (66 MB):** -- **Simple workflows (5-6 nodes):** Keep 7 days โ†’ Save 11 MB -- **Medium workflows (13-14 nodes):** Keep 14 days โ†’ Save 6.7 MB -- **Complex workflows (26+ nodes):** Keep 30 days โ†’ Save 1.9 MB -- **Total Workflow Savings:** 19.6 MB with tiered retention - -**Rationale:** Complex workflows are rarer and more valuable for understanding advanced use cases. - ---- - -## 3. Pruning Recommendations with Space Savings - -### Strategy A: Conservative 14-Day Retention (Recommended for Initial Implementation) - -| Action | Records Deleted | Space Saved | Risk Level | -|--------|----------------|-------------|------------| -| Delete tool_sequence > 14d | 0 | 0 MB | None - all recent | -| Delete tool_used > 14d | 0 | 0 MB | None - all recent | -| Delete validation_details > 14d | 4,259 | 1.2 MB | Low | -| Delete session_start > 14d | 0 | 0 MB | None - all recent | -| Delete workflows > 14d | 1 | <1 KB | None | -| **TOTAL** | **4,260** | **1.2 MB** | **Low** | - -**Assessment:** Minimal immediate impact but data is too recent. Not sufficient to prevent overflow. - -### Strategy B: Aggressive 7-Day Retention (RECOMMENDED) - -| Action | Records Deleted | Space Saved | Risk Level | -|--------|----------------|-------------|------------| -| Delete tool_sequence > 7d | 155,389 | 29 MB | Low - pattern data | -| Delete tool_used > 7d | 82,827 | 6.2 MB | Low - usage metrics | -| Delete validation_details > 7d | 17,465 | 5.4 MB | Medium - debugging data | -| Delete workflow_created > 7d | 9,106 | 1.0 MB | Low - creation events | -| Delete session_start > 7d | 5,664 | 497 KB | Low - session data | -| Delete error_occurred > 7d | 2,321 | 206 KB | Medium - error history | -| Delete workflow_validation_failed > 7d | 5,269 | 170 KB | Low - validation events | -| Delete workflows > 7d (simple) | 5,146 | 11 MB | Low - simple workflows | -| Delete workflows > 7d (medium) | 1,506 | 6.7 MB | Medium - medium workflows | -| Delete workflows > 7d (complex) | 231 | 1.9 MB | High - complex workflows | -| **TOTAL** | **284,924** | **62.1 MB** | **Medium** | - -**New Database Size:** 265 MB - 62.1 MB = **202.9 MB (76.6% of limit)** -**Buffer:** 297 MB remaining (~38 days at current growth rate) - -### Strategy C: Hybrid Tiered Retention (OPTIMAL LONG-TERM) - -| Event Type | Retention Period | Records Deleted | Space Saved | -|-----------|------------------|----------------|-------------| -| tool_sequence | 7 days | 155,389 | 29 MB | -| tool_used | 7 days | 82,827 | 6.2 MB | -| validation_details | 14 days | 4,259 | 1.2 MB | -| workflow_created | 14 days | 3 | <1 KB | -| session_start | 7 days | 5,664 | 497 KB | -| error_occurred | 30 days (keep all) | 0 | 0 MB | -| workflow_validation_failed | 7 days | 5,269 | 170 KB | -| search_query | 7 days | 10 | 1 KB | -| Workflows (simple) | 7 days | 5,146 | 11 MB | -| Workflows (medium) | 14 days | 0 | 0 MB | -| Workflows (complex) | 30 days (keep all) | 0 | 0 MB | -| **TOTAL** | **Various** | **258,567** | **48.1 MB** | - -**New Database Size:** 265 MB - 48.1 MB = **216.9 MB (82% of limit)** -**Buffer:** 283 MB remaining (~36 days at current growth rate) - ---- - -## 4. Additional Optimization Opportunities - -### Optimization 1: Properties Field Compression - -**Finding:** validation_details events have bloated properties (avg 329 bytes, max 9 KB) - -```sql --- Identify large validation_details records -SELECT id, user_id, created_at, pg_column_size(properties) as size_bytes -FROM telemetry_events -WHERE event = 'validation_details' - AND pg_column_size(properties) > 1000 -ORDER BY size_bytes DESC; --- Result: 417 records > 1KB, 2 records > 5KB -``` - -**Recommendation:** Truncate verbose error messages in validation_details after 7 days -- Keep error types and counts -- Remove full stack traces and detailed messages -- Estimated savings: 2-3 MB - -### Optimization 2: Remove Redundant tool_sequence Data - -**Finding:** tool_sequence properties contain mostly null values - -```sql --- Analysis shows all tool_sequence.properties->>'tools' are null --- 362,170 records storing null in properties field -``` - -**Recommendation:** -1. Investigate why tool_sequence properties are empty -2. If by design, reduce properties field size or use a flag -3. Potential savings: 10-15 MB if properties field is eliminated - -### Optimization 3: Workflow Deduplication by Hash - -**Finding:** No duplicate workflow_hash values found (good!) - -**Recommendation:** Continue using workflow_hash for future deduplication if needed. No action required. - -### Optimization 4: Dead Row Cleanup - -**Finding:** telemetry_workflows has 1,591 dead rows (9.5% overhead) - -```sql --- Run VACUUM to reclaim space -VACUUM FULL telemetry_workflows; --- Expected savings: ~6-7 MB -``` - -**Recommendation:** Schedule weekly VACUUM operations - -### Optimization 5: Index Optimization - -**Current indexes consume space but improve query performance** - -```sql --- Check index sizes -SELECT - schemaname, tablename, indexname, - pg_size_pretty(pg_relation_size(indexrelid)) as index_size -FROM pg_stat_user_indexes -WHERE schemaname = 'public' -ORDER BY pg_relation_size(indexrelid) DESC; -``` - -**Recommendation:** Review if all indexes are necessary after pruning strategy is implemented - ---- - -## 5. Implementation Strategy - -### Phase 1: Immediate Emergency Pruning (Day 1) - -**Goal:** Free up 60+ MB immediately to prevent overflow - -```sql --- EMERGENCY PRUNING: Delete data older than 7 days -BEGIN; - --- Backup count before deletion -SELECT - event, - COUNT(*) FILTER (WHERE created_at < NOW() - INTERVAL '7 days') as to_delete -FROM telemetry_events -GROUP BY event; - --- Delete old events -DELETE FROM telemetry_events -WHERE created_at < NOW() - INTERVAL '7 days'; --- Expected: ~278,051 rows deleted, ~36.5 MB saved - --- Delete old simple workflows -DELETE FROM telemetry_workflows -WHERE created_at < NOW() - INTERVAL '7 days' - AND complexity = 'simple'; --- Expected: ~5,146 rows deleted, ~11 MB saved - --- Verify new size -SELECT - schemaname, relname, - pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) AS size -FROM pg_stat_user_tables -WHERE schemaname = 'public'; - -COMMIT; - --- Clean up dead rows -VACUUM FULL telemetry_events; -VACUUM FULL telemetry_workflows; -``` - -**Expected Result:** Database size reduced to ~210-220 MB (55-60% buffer remaining) - -### Phase 2: Implement Automated Retention Policy (Week 1) - -**Create a scheduled Supabase Edge Function or pg_cron job** - -```sql --- Create retention policy function -CREATE OR REPLACE FUNCTION apply_retention_policy() -RETURNS void AS $$ -BEGIN - -- Tier 4: 7-day retention for high-volume events - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '7 days' - AND event IN ('tool_sequence', 'tool_used', 'session_start', - 'workflow_validation_failed', 'search_query'); - - -- Tier 3: 14-day retention for medium-value events - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '14 days' - AND event IN ('validation_details', 'workflow_created'); - - -- Tier 1: 30-day retention for errors (keep longer) - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '30 days' - AND event = 'error_occurred'; - - -- Workflow retention by complexity - DELETE FROM telemetry_workflows - WHERE created_at < NOW() - INTERVAL '7 days' - AND complexity = 'simple'; - - DELETE FROM telemetry_workflows - WHERE created_at < NOW() - INTERVAL '14 days' - AND complexity = 'medium'; - - DELETE FROM telemetry_workflows - WHERE created_at < NOW() - INTERVAL '30 days' - AND complexity = 'complex'; - - -- Cleanup - VACUUM telemetry_events; - VACUUM telemetry_workflows; -END; -$$ LANGUAGE plpgsql; - --- Schedule daily execution (using pg_cron extension) -SELECT cron.schedule('retention-policy', '0 2 * * *', 'SELECT apply_retention_policy()'); -``` - -### Phase 3: Create Aggregation Tables (Week 2) - -**Preserve insights while deleting raw data** - -```sql --- Daily tool usage summary -CREATE TABLE IF NOT EXISTS telemetry_daily_tool_stats ( - date DATE NOT NULL, - tool TEXT NOT NULL, - usage_count INTEGER NOT NULL, - unique_users INTEGER NOT NULL, - avg_duration_ms NUMERIC, - error_count INTEGER DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (date, tool) -); - --- Daily validation summary -CREATE TABLE IF NOT EXISTS telemetry_daily_validation_stats ( - date DATE NOT NULL, - node_type TEXT, - total_validations INTEGER NOT NULL, - failed_validations INTEGER NOT NULL, - success_rate NUMERIC, - common_errors JSONB, - created_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (date, node_type) -); - --- Aggregate function to run before pruning -CREATE OR REPLACE FUNCTION aggregate_before_pruning() -RETURNS void AS $$ -BEGIN - -- Aggregate tool usage for data about to be deleted - INSERT INTO telemetry_daily_tool_stats (date, tool, usage_count, unique_users, avg_duration_ms) - SELECT - DATE(created_at) as date, - properties->>'tool' as tool, - COUNT(*) as usage_count, - COUNT(DISTINCT user_id) as unique_users, - AVG((properties->>'duration')::numeric) as avg_duration_ms - FROM telemetry_events - WHERE event = 'tool_used' - AND created_at < NOW() - INTERVAL '7 days' - AND created_at >= NOW() - INTERVAL '8 days' - GROUP BY DATE(created_at), properties->>'tool' - ON CONFLICT (date, tool) DO NOTHING; - - -- Aggregate validation stats - INSERT INTO telemetry_daily_validation_stats (date, node_type, total_validations, failed_validations) - SELECT - DATE(created_at) as date, - properties->>'nodeType' as node_type, - COUNT(*) as total_validations, - COUNT(*) FILTER (WHERE properties->>'valid' = 'false') as failed_validations - FROM telemetry_events - WHERE event = 'validation_details' - AND created_at < NOW() - INTERVAL '14 days' - AND created_at >= NOW() - INTERVAL '15 days' - GROUP BY DATE(created_at), properties->>'nodeType' - ON CONFLICT (date, node_type) DO NOTHING; -END; -$$ LANGUAGE plpgsql; - --- Update cron job to aggregate before pruning -SELECT cron.schedule('aggregate-then-prune', '0 2 * * *', - 'SELECT aggregate_before_pruning(); SELECT apply_retention_policy();'); -``` - -### Phase 4: Monitoring and Alerting (Week 2) - -**Create size monitoring function** - -```sql -CREATE OR REPLACE FUNCTION check_database_size() -RETURNS TABLE( - total_size_mb NUMERIC, - limit_mb NUMERIC, - percent_used NUMERIC, - days_until_full NUMERIC -) AS $$ -DECLARE - current_size_bytes BIGINT; - growth_rate_bytes_per_day NUMERIC; -BEGIN - -- Get current size - SELECT SUM(pg_total_relation_size(schemaname||'.'||relname)) - INTO current_size_bytes - FROM pg_stat_user_tables - WHERE schemaname = 'public'; - - -- Calculate 7-day growth rate - SELECT - (COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days')) * - AVG(pg_column_size(properties)) * (1.0/7) - INTO growth_rate_bytes_per_day - FROM telemetry_events; - - RETURN QUERY - SELECT - ROUND((current_size_bytes / 1024.0 / 1024.0)::numeric, 2) as total_size_mb, - 500.0 as limit_mb, - ROUND((current_size_bytes / 1024.0 / 1024.0 / 500.0 * 100)::numeric, 2) as percent_used, - ROUND((((500.0 * 1024 * 1024) - current_size_bytes) / NULLIF(growth_rate_bytes_per_day, 0))::numeric, 1) as days_until_full; -END; -$$ LANGUAGE plpgsql; - --- Alert function (integrate with external monitoring) -CREATE OR REPLACE FUNCTION alert_if_size_critical() -RETURNS void AS $$ -DECLARE - size_pct NUMERIC; -BEGIN - SELECT percent_used INTO size_pct FROM check_database_size(); - - IF size_pct > 90 THEN - -- Log critical alert - INSERT INTO telemetry_events (user_id, event, properties) - VALUES ('system', 'database_size_critical', - json_build_object('percent_used', size_pct, 'timestamp', NOW())::jsonb); - END IF; -END; -$$ LANGUAGE plpgsql; -``` - ---- - -## 6. Priority Order for Implementation - -### Priority 1: URGENT (Day 1) -1. **Execute Emergency Pruning** - Delete data older than 7 days - - Impact: 47.5 MB saved immediately - - Risk: Low - data already analyzed - - SQL: Provided in Phase 1 - -### Priority 2: HIGH (Week 1) -2. **Implement Automated Retention Policy** - - Impact: Prevents future overflow - - Risk: Low with proper testing - - Implementation: Phase 2 function - -3. **Run VACUUM FULL** - - Impact: 6-7 MB reclaimed from dead rows - - Risk: Low but locks tables briefly - - Command: `VACUUM FULL telemetry_workflows;` - -### Priority 3: MEDIUM (Week 2) -4. **Create Aggregation Tables** - - Impact: Preserves insights, enables longer-term pruning - - Risk: Low - additive only - - Implementation: Phase 3 tables and functions - -5. **Implement Monitoring** - - Impact: Prevents future surprises - - Risk: None - - Implementation: Phase 4 monitoring functions - -### Priority 4: LOW (Month 1) -6. **Optimize Properties Fields** - - Impact: 2-3 MB additional savings - - Risk: Medium - requires code changes - - Action: Truncate verbose error messages - -7. **Investigate tool_sequence null properties** - - Impact: 10-15 MB potential savings - - Risk: Medium - requires application changes - - Action: Code review and optimization - ---- - -## 7. Risk Assessment - -### Strategy B (7-Day Retention): Risks and Mitigations - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|---------|------------| -| Loss of debugging data for old issues | Medium | Medium | Keep error_occurred for 30 days; aggregate validation stats | -| Unable to analyze long-term trends | Low | Low | Implement aggregation tables before pruning | -| Accidental deletion of critical data | Low | High | Test on staging; implement backups; add rollback capability | -| Performance impact during deletion | Medium | Low | Run during off-peak hours (2 AM UTC) | -| VACUUM locks table briefly | Low | Low | Schedule during low-usage window | - -### Strategy C (Hybrid Tiered): Risks and Mitigations - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|---------|------------| -| Complex logic leads to bugs | Medium | Medium | Thorough testing; monitoring; gradual rollout | -| Different retention per event type confusing | Low | Low | Document clearly; add comments in code | -| Tiered approach still insufficient | Low | High | Monitor growth; adjust retention if needed | - ---- - -## 8. Monitoring Metrics - -### Key Metrics to Track Post-Implementation - -1. **Database Size Trend** - ```sql - SELECT * FROM check_database_size(); - ``` - - Target: Stay under 300 MB (60% of limit) - - Alert threshold: 90% (450 MB) - -2. **Daily Growth Rate** - ```sql - SELECT - DATE(created_at) as date, - COUNT(*) as events, - pg_size_pretty(SUM(pg_column_size(properties))::bigint) as daily_size - FROM telemetry_events - WHERE created_at >= NOW() - INTERVAL '7 days' - GROUP BY DATE(created_at) - ORDER BY date DESC; - ``` - - Target: < 8 MB/day average - - Alert threshold: > 12 MB/day sustained - -3. **Retention Policy Execution** - ```sql - -- Add logging to retention policy function - CREATE TABLE retention_policy_log ( - executed_at TIMESTAMPTZ DEFAULT NOW(), - events_deleted INTEGER, - workflows_deleted INTEGER, - space_reclaimed_mb NUMERIC - ); - ``` - - Monitor: Daily successful execution - - Alert: If job fails or deletes 0 rows unexpectedly - -4. **Data Availability Check** - ```sql - -- Ensure sufficient data for analysis - SELECT - event, - COUNT(*) as available_records, - MIN(created_at) as oldest_record, - MAX(created_at) as newest_record - FROM telemetry_events - GROUP BY event; - ``` - - Target: 7 days of data always available - - Alert: If oldest_record > 8 days ago (retention policy failing) - ---- - -## 9. Recommended Action Plan - -### Immediate Actions (Today) - -**Step 1:** Execute emergency pruning -```sql --- Backup first (optional but recommended) --- Create a copy of current stats -CREATE TABLE telemetry_events_stats_backup AS -SELECT event, COUNT(*), MIN(created_at), MAX(created_at) -FROM telemetry_events -GROUP BY event; - --- Execute pruning -DELETE FROM telemetry_events WHERE created_at < NOW() - INTERVAL '7 days'; -DELETE FROM telemetry_workflows WHERE created_at < NOW() - INTERVAL '7 days' AND complexity = 'simple'; -VACUUM FULL telemetry_events; -VACUUM FULL telemetry_workflows; -``` - -**Step 2:** Verify results -```sql -SELECT * FROM check_database_size(); -``` - -**Expected outcome:** Database size ~210-220 MB (58-60% buffer remaining) - -### Week 1 Actions - -**Step 3:** Implement automated retention policy -- Create retention policy function (Phase 2 code) -- Test function on staging/development environment -- Schedule daily execution via pg_cron - -**Step 4:** Set up monitoring -- Create monitoring functions (Phase 4 code) -- Configure alerts for size thresholds -- Document escalation procedures - -### Week 2 Actions - -**Step 5:** Create aggregation tables -- Implement summary tables (Phase 3 code) -- Backfill historical aggregations if needed -- Update retention policy to aggregate before pruning - -**Step 6:** Optimize and tune -- Review query performance post-pruning -- Adjust retention periods if needed based on actual usage -- Document any issues or improvements - -### Monthly Maintenance - -**Step 7:** Regular review -- Monthly review of database growth trends -- Quarterly review of retention policy effectiveness -- Adjust retention periods based on product needs - ---- - -## 10. SQL Execution Scripts - -### Script 1: Emergency Pruning (Run First) - -```sql --- ============================================ --- EMERGENCY PRUNING SCRIPT --- Expected savings: ~50 MB --- Execution time: 2-5 minutes --- ============================================ - -BEGIN; - --- Create backup of current state -CREATE TABLE IF NOT EXISTS pruning_audit ( - executed_at TIMESTAMPTZ DEFAULT NOW(), - action TEXT, - records_affected INTEGER, - size_before_mb NUMERIC, - size_after_mb NUMERIC -); - --- Record size before -INSERT INTO pruning_audit (action, size_before_mb) -SELECT 'before_pruning', - pg_total_relation_size('telemetry_events')::numeric / 1024 / 1024; - --- Delete old events (keep last 7 days) -WITH deleted AS ( - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '7 days' - RETURNING * -) -INSERT INTO pruning_audit (action, records_affected) -SELECT 'delete_events_7d', COUNT(*) FROM deleted; - --- Delete old simple workflows (keep last 7 days) -WITH deleted AS ( - DELETE FROM telemetry_workflows - WHERE created_at < NOW() - INTERVAL '7 days' - AND complexity = 'simple' - RETURNING * -) -INSERT INTO pruning_audit (action, records_affected) -SELECT 'delete_workflows_simple_7d', COUNT(*) FROM deleted; - --- Record size after -UPDATE pruning_audit -SET size_after_mb = pg_total_relation_size('telemetry_events')::numeric / 1024 / 1024 -WHERE action = 'before_pruning'; - -COMMIT; - --- Cleanup dead space -VACUUM FULL telemetry_events; -VACUUM FULL telemetry_workflows; - --- Verify results -SELECT * FROM pruning_audit ORDER BY executed_at DESC LIMIT 5; -SELECT * FROM check_database_size(); -``` - -### Script 2: Create Retention Policy (Run After Testing) - -```sql --- ============================================ --- AUTOMATED RETENTION POLICY --- Schedule: Daily at 2 AM UTC --- ============================================ - -CREATE OR REPLACE FUNCTION apply_retention_policy() -RETURNS TABLE( - action TEXT, - records_deleted INTEGER, - execution_time_ms INTEGER -) AS $$ -DECLARE - start_time TIMESTAMPTZ; - end_time TIMESTAMPTZ; - deleted_count INTEGER; -BEGIN - -- Tier 4: 7-day retention (high volume, low long-term value) - start_time := clock_timestamp(); - - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '7 days' - AND event IN ('tool_sequence', 'tool_used', 'session_start', - 'workflow_validation_failed', 'search_query'); - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - end_time := clock_timestamp(); - action := 'delete_tier4_7d'; - records_deleted := deleted_count; - execution_time_ms := EXTRACT(MILLISECONDS FROM (end_time - start_time))::INTEGER; - RETURN NEXT; - - -- Tier 3: 14-day retention (medium value) - start_time := clock_timestamp(); - - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '14 days' - AND event IN ('validation_details', 'workflow_created'); - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - end_time := clock_timestamp(); - action := 'delete_tier3_14d'; - records_deleted := deleted_count; - execution_time_ms := EXTRACT(MILLISECONDS FROM (end_time - start_time))::INTEGER; - RETURN NEXT; - - -- Tier 1: 30-day retention (errors - keep longer) - start_time := clock_timestamp(); - - DELETE FROM telemetry_events - WHERE created_at < NOW() - INTERVAL '30 days' - AND event = 'error_occurred'; - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - end_time := clock_timestamp(); - action := 'delete_errors_30d'; - records_deleted := deleted_count; - execution_time_ms := EXTRACT(MILLISECONDS FROM (end_time - start_time))::INTEGER; - RETURN NEXT; - - -- Workflow pruning by complexity - start_time := clock_timestamp(); - - DELETE FROM telemetry_workflows - WHERE created_at < NOW() - INTERVAL '7 days' - AND complexity = 'simple'; - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - end_time := clock_timestamp(); - action := 'delete_workflows_simple_7d'; - records_deleted := deleted_count; - execution_time_ms := EXTRACT(MILLISECONDS FROM (end_time - start_time))::INTEGER; - RETURN NEXT; - - start_time := clock_timestamp(); - - DELETE FROM telemetry_workflows - WHERE created_at < NOW() - INTERVAL '14 days' - AND complexity = 'medium'; - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - end_time := clock_timestamp(); - action := 'delete_workflows_medium_14d'; - records_deleted := deleted_count; - execution_time_ms := EXTRACT(MILLISECONDS FROM (end_time - start_time))::INTEGER; - RETURN NEXT; - - start_time := clock_timestamp(); - - DELETE FROM telemetry_workflows - WHERE created_at < NOW() - INTERVAL '30 days' - AND complexity = 'complex'; - GET DIAGNOSTICS deleted_count = ROW_COUNT; - - end_time := clock_timestamp(); - action := 'delete_workflows_complex_30d'; - records_deleted := deleted_count; - execution_time_ms := EXTRACT(MILLISECONDS FROM (end_time - start_time))::INTEGER; - RETURN NEXT; - - -- Vacuum to reclaim space - start_time := clock_timestamp(); - VACUUM telemetry_events; - VACUUM telemetry_workflows; - end_time := clock_timestamp(); - - action := 'vacuum_tables'; - records_deleted := 0; - execution_time_ms := EXTRACT(MILLISECONDS FROM (end_time - start_time))::INTEGER; - RETURN NEXT; -END; -$$ LANGUAGE plpgsql; - --- Test the function (dry run - won't schedule yet) -SELECT * FROM apply_retention_policy(); - --- After testing, schedule with pg_cron --- Requires pg_cron extension: CREATE EXTENSION IF NOT EXISTS pg_cron; --- SELECT cron.schedule('retention-policy', '0 2 * * *', 'SELECT apply_retention_policy()'); -``` - -### Script 3: Create Monitoring Dashboard - -```sql --- ============================================ --- MONITORING QUERIES --- Run these regularly to track database health --- ============================================ - --- Query 1: Current database size and projections -SELECT - 'Current Size' as metric, - pg_size_pretty(SUM(pg_total_relation_size(schemaname||'.'||relname))) as value -FROM pg_stat_user_tables -WHERE schemaname = 'public' -UNION ALL -SELECT - 'Free Tier Limit' as metric, - '500 MB' as value -UNION ALL -SELECT - 'Percent Used' as metric, - CONCAT( - ROUND( - (SUM(pg_total_relation_size(schemaname||'.'||relname))::numeric / - (500.0 * 1024 * 1024) * 100), - 2 - ), - '%' - ) as value -FROM pg_stat_user_tables -WHERE schemaname = 'public'; - --- Query 2: Data age distribution -SELECT - event, - COUNT(*) as total_records, - MIN(created_at) as oldest_record, - MAX(created_at) as newest_record, - ROUND(EXTRACT(EPOCH FROM (MAX(created_at) - MIN(created_at))) / 86400, 2) as age_days -FROM telemetry_events -GROUP BY event -ORDER BY total_records DESC; - --- Query 3: Daily growth tracking (last 7 days) -SELECT - DATE(created_at) as date, - COUNT(*) as daily_events, - pg_size_pretty(SUM(pg_column_size(properties))::bigint) as daily_data_size, - COUNT(DISTINCT user_id) as active_users -FROM telemetry_events -WHERE created_at >= NOW() - INTERVAL '7 days' -GROUP BY DATE(created_at) -ORDER BY date DESC; - --- Query 4: Retention policy effectiveness -SELECT - DATE(executed_at) as execution_date, - action, - records_deleted, - execution_time_ms -FROM ( - SELECT * FROM apply_retention_policy() -) AS policy_run -ORDER BY execution_date DESC; -``` - ---- - -## Conclusion - -**Immediate Action Required:** Implement Strategy B (7-day retention) immediately to avoid database overflow within 2 weeks. - -**Long-Term Strategy:** Transition to Strategy C (Hybrid Tiered Retention) with automated aggregation to balance data preservation with storage constraints. - -**Expected Outcomes:** -- Immediate: 50+ MB saved (26% reduction) -- Ongoing: Database stabilized at 200-220 MB (40-44% of limit) -- Buffer: 30-40 days before limit with current growth rate -- Risk: Low with proper testing and monitoring - -**Success Metrics:** -1. Database size < 300 MB consistently -2. 7+ days of detailed event data always available -3. No impact on product analytics capabilities -4. Automated retention policy runs daily without errors - ---- - -**Analysis completed:** 2025-10-10 -**Next review date:** 2025-11-10 (monthly check) -**Escalation:** If database exceeds 400 MB, consider upgrading to paid tier or implementing more aggressive pruning diff --git a/tests/integration/session-lifecycle-retry.test.ts b/tests/integration/session-lifecycle-retry.test.ts deleted file mode 100644 index a3bf8ff..0000000 --- a/tests/integration/session-lifecycle-retry.test.ts +++ /dev/null @@ -1,747 +0,0 @@ -/** - * Integration tests for Session Lifecycle Events (Phase 3) and Retry Policy (Phase 4) - * - * Tests complete event flow and retry behavior in realistic scenarios - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { N8NMCPEngine } from '../../src/mcp-engine'; -import { InstanceContext } from '../../src/types/instance-context'; -import { SessionRestoreHook, SessionState } from '../../src/types/session-restoration'; -import type { Request, Response } from 'express'; - -// In-memory session storage for testing -const sessionStorage: Map = new Map(); - -/** - * Mock session store with failure simulation - */ -class MockSessionStore { - private failureCount = 0; - private maxFailures = 0; - - /** - * Configure transient failures for retry testing - */ - setTransientFailures(count: number): void { - this.failureCount = 0; - this.maxFailures = count; - } - - async saveSession(sessionState: SessionState): Promise { - sessionStorage.set(sessionState.sessionId, { - ...sessionState, - lastAccess: sessionState.lastAccess || new Date(), - expiresAt: sessionState.expiresAt || new Date(Date.now() + 30 * 60 * 1000) - }); - } - - async loadSession(sessionId: string): Promise { - // Simulate transient failures - if (this.failureCount < this.maxFailures) { - this.failureCount++; - throw new Error(`Transient database error (attempt ${this.failureCount})`); - } - - const session = sessionStorage.get(sessionId); - if (!session) return null; - - // Check if expired - if (session.expiresAt < new Date()) { - sessionStorage.delete(sessionId); - return null; - } - - return session.instanceContext; - } - - async deleteSession(sessionId: string): Promise { - sessionStorage.delete(sessionId); - } - - clear(): void { - sessionStorage.clear(); - this.failureCount = 0; - this.maxFailures = 0; - } -} - -describe('Session Lifecycle Events & Retry Policy Integration Tests', () => { - const TEST_AUTH_TOKEN = 'lifecycle-retry-test-token-32-chars-min'; - let mockStore: MockSessionStore; - let originalEnv: NodeJS.ProcessEnv; - - // Event tracking - let eventLog: Array<{ event: string; sessionId: string; timestamp: number }> = []; - - beforeEach(() => { - // Save and set environment - originalEnv = { ...process.env }; - process.env.AUTH_TOKEN = TEST_AUTH_TOKEN; - process.env.PORT = '0'; - process.env.NODE_ENV = 'test'; - // Use in-memory database for tests - these tests focus on session lifecycle, - // not node queries, so we don't need the full node database - process.env.NODE_DB_PATH = ':memory:'; - - // Clear storage and events - mockStore = new MockSessionStore(); - mockStore.clear(); - eventLog = []; - }); - - afterEach(() => { - // Restore environment - process.env = originalEnv; - mockStore.clear(); - eventLog = []; - vi.clearAllMocks(); - }); - - // Helper to create properly mocked Request and Response objects - // Simplified to match working session-persistence test - SDK doesn't need full socket mock - function createMockReqRes(sessionId?: string, body?: any) { - const req = { - method: 'POST', - path: '/mcp', - url: '/mcp', - originalUrl: '/mcp', - headers: { - 'authorization': `Bearer ${TEST_AUTH_TOKEN}`, - ...(sessionId && { 'mcp-session-id': sessionId }) - } as Record, - body: body || { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 1 - }, - ip: '127.0.0.1', - readable: true, - readableEnded: false, - complete: true, - get: vi.fn((header: string) => req.headers[header.toLowerCase()]), - on: vi.fn((event: string, handler: Function) => {}), - removeListener: vi.fn((event: string, handler: Function) => {}) - } as any as Request; - - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - setHeader: vi.fn(), - send: vi.fn().mockReturnThis(), - writeHead: vi.fn().mockReturnThis(), - write: vi.fn(), - end: vi.fn(), - flushHeaders: vi.fn(), - on: vi.fn((event: string, handler: Function) => res), - once: vi.fn((event: string, handler: Function) => res), - removeListener: vi.fn(), - headersSent: false, - finished: false - } as any as Response; - - return { req, res }; - } - - // Helper to track events - function createEventTracker() { - return { - onSessionCreated: vi.fn((sessionId: string) => { - eventLog.push({ event: 'created', sessionId, timestamp: Date.now() }); - }), - onSessionRestored: vi.fn((sessionId: string) => { - eventLog.push({ event: 'restored', sessionId, timestamp: Date.now() }); - }), - onSessionAccessed: vi.fn((sessionId: string) => { - eventLog.push({ event: 'accessed', sessionId, timestamp: Date.now() }); - }), - onSessionExpired: vi.fn((sessionId: string) => { - eventLog.push({ event: 'expired', sessionId, timestamp: Date.now() }); - }), - onSessionDeleted: vi.fn((sessionId: string) => { - eventLog.push({ event: 'deleted', sessionId, timestamp: Date.now() }); - }) - }; - } - - describe('Phase 3: Session Lifecycle Events', () => { - it('should emit onSessionCreated for new sessions', async () => { - const events = createEventTracker(); - const engine = new N8NMCPEngine({ - sessionEvents: events - }); - - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - // Create session using public API - const sessionId = 'instance-test-abc-new-session-lifecycle-test'; - const created = engine.restoreSession(sessionId, context); - - expect(created).toBe(true); - - // Give fire-and-forget events a moment - await new Promise(resolve => setTimeout(resolve, 50)); - - // Should have emitted onSessionCreated - expect(events.onSessionCreated).toHaveBeenCalledTimes(1); - expect(events.onSessionCreated).toHaveBeenCalledWith(sessionId, context); - - await engine.shutdown(); - }); - - it('should emit onSessionRestored when restoring from storage', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://tenant1.n8n.cloud', - n8nApiKey: 'tenant1-key', - instanceId: 'tenant-1' - }; - - const sessionId = 'instance-tenant-1-abc-restored-session-test'; - - // Persist session - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - const restorationHook: SessionRestoreHook = async (sid) => { - return await mockStore.loadSession(sid); - }; - - const events = createEventTracker(); - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionEvents: events - }); - - // Process request that triggers restoration (DON'T pass context - let it restore) - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - await engine.processRequest(mockReq, mockRes); - - // Give fire-and-forget events a moment - await new Promise(resolve => setTimeout(resolve, 50)); - - // Should emit onSessionRestored (not onSessionCreated) - // Note: If context was passed to processRequest, it would create instead of restore - expect(events.onSessionRestored).toHaveBeenCalledTimes(1); - expect(events.onSessionRestored).toHaveBeenCalledWith(sessionId, context); - - await engine.shutdown(); - }); - - it('should emit onSessionDeleted when session is manually deleted', async () => { - const events = createEventTracker(); - const engine = new N8NMCPEngine({ - sessionEvents: events - }); - - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-testinstance-abc-550e8400e29b41d4a716446655440001'; - - // Create session by calling restoreSession - const created = engine.restoreSession(sessionId, context); - expect(created).toBe(true); - - // Verify session exists - expect(engine.getActiveSessions()).toContain(sessionId); - - // Give creation event time to fire - await new Promise(resolve => setTimeout(resolve, 50)); - - // Delete session - const deleted = engine.deleteSession(sessionId); - expect(deleted).toBe(true); - - // Verify session was deleted - expect(engine.getActiveSessions()).not.toContain(sessionId); - - // Give deletion event time to fire - await new Promise(resolve => setTimeout(resolve, 50)); - - // Should emit onSessionDeleted - expect(events.onSessionDeleted).toHaveBeenCalledTimes(1); - expect(events.onSessionDeleted).toHaveBeenCalledWith(sessionId); - - await engine.shutdown(); - }); - - it('should handle event handler errors gracefully', async () => { - const errorHandler = vi.fn(() => { - throw new Error('Event handler error'); - }); - - const engine = new N8NMCPEngine({ - sessionEvents: { - onSessionCreated: errorHandler - } - }); - - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-test-abc-error-handler-test'; - - // Should not throw despite handler error - expect(() => { - engine.restoreSession(sessionId, context); - }).not.toThrow(); - - // Session should still be created - expect(engine.getActiveSessions()).toContain(sessionId); - - await engine.shutdown(); - }); - - it('should emit events with correct metadata', async () => { - const events = createEventTracker(); - const engine = new N8NMCPEngine({ - sessionEvents: events - }); - - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance', - metadata: { - userId: 'user-456', - tier: 'enterprise' - } - }; - - const sessionId = 'instance-test-abc-metadata-test'; - engine.restoreSession(sessionId, context); - - // Give event time to fire - await new Promise(resolve => setTimeout(resolve, 50)); - - expect(events.onSessionCreated).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ - metadata: { - userId: 'user-456', - tier: 'enterprise' - } - }) - ); - - await engine.shutdown(); - }); - }); - - describe('Phase 4: Retry Policy', () => { - it('should retry transient failures and eventually succeed', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-testinst-abc-550e8400e29b41d4a716446655440002'; - - // Persist session - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - // Configure to fail twice, then succeed - mockStore.setTransientFailures(2); - - const restorationHook: SessionRestoreHook = async (sid) => { - return await mockStore.loadSession(sid); - }; - - const events = createEventTracker(); - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationRetries: 3, // Allow up to 3 retries - sessionRestorationRetryDelay: 50, // Fast retries for testing - sessionEvents: events - }); - - const { req: mockReq, res: mockRes} = createMockReqRes(sessionId); - await engine.processRequest(mockReq, mockRes); // Don't pass context - let it restore - - // Give events time to fire - await new Promise(resolve => setTimeout(resolve, 100)); - - // Should have succeeded (not 500 error) - expect(mockRes.status).not.toHaveBeenCalledWith(500); - - // Should emit onSessionRestored after successful retry - expect(events.onSessionRestored).toHaveBeenCalledTimes(1); - - await engine.shutdown(); - }); - - it('should fail after exhausting all retries', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-test-abc-retry-exhaust-test'; - - // Persist session - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - // Configure to fail 5 times (more than max retries) - mockStore.setTransientFailures(5); - - const restorationHook: SessionRestoreHook = async (sid) => { - return await mockStore.loadSession(sid); - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationRetries: 2, // Only 2 retries - sessionRestorationRetryDelay: 50 - }); - - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - await engine.processRequest(mockReq, mockRes); // Don't pass context - - // Should fail with 500 error - expect(mockRes.status).toHaveBeenCalledWith(500); - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringMatching(/restoration failed|error/i) - }) - }) - ); - - await engine.shutdown(); - }); - - it('should not retry timeout errors', async () => { - const slowHook: SessionRestoreHook = async () => { - // Simulate very slow query - await new Promise(resolve => setTimeout(resolve, 500)); - return { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test' - }; - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: slowHook, - sessionRestorationRetries: 3, - sessionRestorationRetryDelay: 50, - sessionRestorationTimeout: 100 // Very short timeout - }); - - const { req: mockReq, res: mockRes } = createMockReqRes('instance-test-abc-timeout-no-retry'); - await engine.processRequest(mockReq, mockRes); - - // Should timeout with 408 - expect(mockRes.status).toHaveBeenCalledWith(408); - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringMatching(/timeout|timed out/i) - }) - }) - ); - - await engine.shutdown(); - }); - - it('should respect overall timeout across all retry attempts', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-test-abc-overall-timeout-test'; - - // Persist session - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - // Configure many failures - mockStore.setTransientFailures(10); - - const restorationHook: SessionRestoreHook = async (sid) => { - // Each attempt takes 100ms - await new Promise(resolve => setTimeout(resolve, 100)); - return await mockStore.loadSession(sid); - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationRetries: 10, // Many retries - sessionRestorationRetryDelay: 100, - sessionRestorationTimeout: 300 // Overall timeout for ALL attempts - }); - - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - await engine.processRequest(mockReq, mockRes); // Don't pass context - - // Should timeout before exhausting retries - expect(mockRes.status).toHaveBeenCalledWith(408); - - await engine.shutdown(); - }); - }); - - describe('Phase 3 + 4: Combined Behavior', () => { - it('should emit onSessionRestored after successful retry', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-testinst-abc-550e8400e29b41d4a716446655440003'; - - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - // Fail once, then succeed - mockStore.setTransientFailures(1); - - const restorationHook: SessionRestoreHook = async (sid) => { - return await mockStore.loadSession(sid); - }; - - const events = createEventTracker(); - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationRetries: 2, - sessionRestorationRetryDelay: 50, - sessionEvents: events - }); - - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - await engine.processRequest(mockReq, mockRes); // Don't pass context - - // Give events time to fire - await new Promise(resolve => setTimeout(resolve, 100)); - - // Should have succeeded - expect(mockRes.status).not.toHaveBeenCalledWith(500); - - // Should emit onSessionRestored after successful retry - expect(events.onSessionRestored).toHaveBeenCalledTimes(1); - expect(events.onSessionRestored).toHaveBeenCalledWith(sessionId, context); - - await engine.shutdown(); - }); - - it('should not emit events if all retries fail', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-test-abc-retry-fail-no-event'; - - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - // Always fail - mockStore.setTransientFailures(10); - - const restorationHook: SessionRestoreHook = async (sid) => { - return await mockStore.loadSession(sid); - }; - - const events = createEventTracker(); - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationRetries: 2, - sessionRestorationRetryDelay: 50, - sessionEvents: events - }); - - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - await engine.processRequest(mockReq, mockRes); // Don't pass context - - // Give events time to fire (they shouldn't) - await new Promise(resolve => setTimeout(resolve, 100)); - - // Should have failed - expect(mockRes.status).toHaveBeenCalledWith(500); - - // Should NOT emit onSessionRestored - expect(events.onSessionRestored).not.toHaveBeenCalled(); - expect(events.onSessionCreated).not.toHaveBeenCalled(); - - await engine.shutdown(); - }); - - it('should handle event handler errors during retry workflow', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-testinst-abc-550e8400e29b41d4a716446655440004'; - - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - // Fail once, then succeed - mockStore.setTransientFailures(1); - - const restorationHook: SessionRestoreHook = async (sid) => { - return await mockStore.loadSession(sid); - }; - - const errorHandler = vi.fn(() => { - throw new Error('Event handler error'); - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationRetries: 2, - sessionRestorationRetryDelay: 50, - sessionEvents: { - onSessionRestored: errorHandler - } - }); - - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - - // Should not throw despite event handler error - await engine.processRequest(mockReq, mockRes); // Don't pass context - - // Give event handler time to fail - await new Promise(resolve => setTimeout(resolve, 100)); - - // Request should still succeed (event error is non-blocking) - expect(mockRes.status).not.toHaveBeenCalledWith(500); - - // Handler was called - expect(errorHandler).toHaveBeenCalledTimes(1); - - await engine.shutdown(); - }); - }); - - describe('Backward Compatibility', () => { - it('should work without lifecycle events configured', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-testinst-abc-550e8400e29b41d4a716446655440005'; - - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - const restorationHook: SessionRestoreHook = async (sid) => { - return await mockStore.loadSession(sid); - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook - // No sessionEvents configured - }); - - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - await engine.processRequest(mockReq, mockRes); // Don't pass context - - // Should work normally - expect(mockRes.status).not.toHaveBeenCalledWith(500); - - await engine.shutdown(); - }); - - it('should work with 0 retries (default behavior)', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = 'instance-test-abc-zero-retries'; - - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - // Fail once - mockStore.setTransientFailures(1); - - const restorationHook: SessionRestoreHook = async (sid) => { - return await mockStore.loadSession(sid); - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook - // No sessionRestorationRetries - defaults to 0 - }); - - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - await engine.processRequest(mockReq, mockRes, context); - - // Should fail immediately (no retries) - expect(mockRes.status).toHaveBeenCalledWith(500); - - await engine.shutdown(); - }); - }); -}); diff --git a/tests/integration/session-persistence.test.ts b/tests/integration/session-persistence.test.ts deleted file mode 100644 index 98b8a94..0000000 --- a/tests/integration/session-persistence.test.ts +++ /dev/null @@ -1,600 +0,0 @@ -/** - * Integration tests for session persistence (Phase 1) - * - * Tests the complete session restoration flow end-to-end, - * simulating real-world scenarios like container restarts and multi-tenant usage. - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { N8NMCPEngine } from '../../src/mcp-engine'; -import { SingleSessionHTTPServer } from '../../src/http-server-single-session'; -import { InstanceContext } from '../../src/types/instance-context'; -import { SessionRestoreHook, SessionState } from '../../src/types/session-restoration'; -import type { Request, Response } from 'express'; - -// In-memory session storage for testing -const sessionStorage: Map = new Map(); - -/** - * Simulates a backend database for session persistence - */ -class MockSessionStore { - async saveSession(sessionState: SessionState): Promise { - sessionStorage.set(sessionState.sessionId, { - ...sessionState, - // Only update lastAccess and expiresAt if not provided - lastAccess: sessionState.lastAccess || new Date(), - expiresAt: sessionState.expiresAt || new Date(Date.now() + 30 * 60 * 1000) // 30 minutes - }); - } - - async loadSession(sessionId: string): Promise { - const session = sessionStorage.get(sessionId); - if (!session) return null; - - // Check if expired - if (session.expiresAt < new Date()) { - sessionStorage.delete(sessionId); - return null; - } - - // Update last access - session.lastAccess = new Date(); - session.expiresAt = new Date(Date.now() + 30 * 60 * 1000); - sessionStorage.set(sessionId, session); - - return session; - } - - async deleteSession(sessionId: string): Promise { - sessionStorage.delete(sessionId); - } - - async cleanExpired(): Promise { - const now = new Date(); - let count = 0; - - for (const [sessionId, session] of sessionStorage.entries()) { - if (session.expiresAt < now) { - sessionStorage.delete(sessionId); - count++; - } - } - - return count; - } - - getAllSessions(): Map { - return new Map(sessionStorage); - } - - clear(): void { - sessionStorage.clear(); - } -} - -describe('Session Persistence Integration Tests', () => { - const TEST_AUTH_TOKEN = 'integration-test-token-with-32-chars-min-length'; - let mockStore: MockSessionStore; - let originalEnv: NodeJS.ProcessEnv; - - beforeEach(() => { - // Save and set environment - originalEnv = { ...process.env }; - process.env.AUTH_TOKEN = TEST_AUTH_TOKEN; - process.env.PORT = '0'; - process.env.NODE_ENV = 'test'; - - // Clear session storage - mockStore = new MockSessionStore(); - mockStore.clear(); - }); - - afterEach(() => { - // Restore environment - process.env = originalEnv; - mockStore.clear(); - }); - - // Helper to create properly mocked Request and Response objects - function createMockReqRes(sessionId?: string, body?: any) { - const req = { - method: 'POST', - path: '/mcp', - url: '/mcp', - originalUrl: '/mcp', - headers: { - 'authorization': `Bearer ${TEST_AUTH_TOKEN}`, - ...(sessionId && { 'mcp-session-id': sessionId }) - } as Record, - body: body || { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 1 - }, - ip: '127.0.0.1', - readable: true, - readableEnded: false, - complete: true, - get: vi.fn((header: string) => req.headers[header.toLowerCase()]), - on: vi.fn((event: string, handler: Function) => {}), - removeListener: vi.fn((event: string, handler: Function) => {}) - } as any as Request; - - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - setHeader: vi.fn(), - send: vi.fn().mockReturnThis(), - headersSent: false, - finished: false - } as any as Response; - - return { req, res }; - } - - describe('Container Restart Simulation', () => { - it('should restore session after simulated container restart', async () => { - // PHASE 1: Initial session creation - const context: InstanceContext = { - n8nApiUrl: 'https://tenant1.n8n.cloud', - n8nApiKey: 'tenant1-api-key', - instanceId: 'tenant-1' - }; - - const sessionId = 'instance-tenant-1-abc-550e8400-e29b-41d4-a716-446655440000'; - - // Simulate session being persisted by the backend - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - // PHASE 2: Simulate container restart (create new engine) - const restorationHook: SessionRestoreHook = async (sid) => { - const session = await mockStore.loadSession(sid); - return session ? session.instanceContext : null; - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationTimeout: 5000 - }); - - // PHASE 3: Client tries to use old session ID - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - - // Should successfully restore and process request - await engine.processRequest(mockReq, mockRes, context); - - // Session should be restored (not return 400 for unknown session) - expect(mockRes.status).not.toHaveBeenCalledWith(400); - expect(mockRes.status).not.toHaveBeenCalledWith(404); - - await engine.shutdown(); - }); - - it('should reject expired sessions after container restart', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://tenant1.n8n.cloud', - n8nApiKey: 'tenant1-api-key', - instanceId: 'tenant-1' - }; - - const sessionId = '550e8400-e29b-41d4-a716-446655440000'; - - // Save session with past expiration - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago - lastAccess: new Date(Date.now() - 45 * 60 * 1000), // 45 minutes ago - expiresAt: new Date(Date.now() - 15 * 60 * 1000) // Expired 15 minutes ago - }); - - const restorationHook: SessionRestoreHook = async (sid) => { - const session = await mockStore.loadSession(sid); - return session ? session.instanceContext : null; - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationTimeout: 5000 - }); - - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId); - - await engine.processRequest(mockReq, mockRes); - - // Should reject expired session - expect(mockRes.status).toHaveBeenCalledWith(400); - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringMatching(/session|not found/i) - }) - }) - ); - - await engine.shutdown(); - }); - }); - - describe('Multi-Tenant Session Restoration', () => { - it('should restore correct instance context for each tenant', async () => { - // Create sessions for multiple tenants - const tenant1Context: InstanceContext = { - n8nApiUrl: 'https://tenant1.n8n.cloud', - n8nApiKey: 'tenant1-key', - instanceId: 'tenant-1' - }; - - const tenant2Context: InstanceContext = { - n8nApiUrl: 'https://tenant2.n8n.cloud', - n8nApiKey: 'tenant2-key', - instanceId: 'tenant-2' - }; - - const sessionId1 = 'instance-tenant-1-abc-550e8400-e29b-41d4-a716-446655440000'; - const sessionId2 = 'instance-tenant-2-xyz-f47ac10b-58cc-4372-a567-0e02b2c3d479'; - - await mockStore.saveSession({ - sessionId: sessionId1, - instanceContext: tenant1Context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - await mockStore.saveSession({ - sessionId: sessionId2, - instanceContext: tenant2Context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - const restorationHook: SessionRestoreHook = async (sid) => { - const session = await mockStore.loadSession(sid); - return session ? session.instanceContext : null; - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationTimeout: 5000 - }); - - // Verify each tenant gets their own context - const session1 = await mockStore.loadSession(sessionId1); - const session2 = await mockStore.loadSession(sessionId2); - - expect(session1?.instanceContext.instanceId).toBe('tenant-1'); - expect(session1?.instanceContext.n8nApiUrl).toBe('https://tenant1.n8n.cloud'); - - expect(session2?.instanceContext.instanceId).toBe('tenant-2'); - expect(session2?.instanceContext.n8nApiUrl).toBe('https://tenant2.n8n.cloud'); - - await engine.shutdown(); - }); - - it('should isolate sessions between tenants', async () => { - const tenant1Context: InstanceContext = { - n8nApiUrl: 'https://tenant1.n8n.cloud', - n8nApiKey: 'tenant1-key', - instanceId: 'tenant-1' - }; - - const sessionId = 'instance-tenant-1-abc-550e8400-e29b-41d4-a716-446655440000'; - - await mockStore.saveSession({ - sessionId, - instanceContext: tenant1Context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - const restorationHook: SessionRestoreHook = async (sid) => { - const session = await mockStore.loadSession(sid); - return session ? session.instanceContext : null; - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook - }); - - // Tenant 2 tries to use tenant 1's session ID - const wrongSessionId = sessionId; // Tenant 1's ID - const { req: tenant2Request, res: mockRes } = createMockReqRes(wrongSessionId); - - // The restoration will succeed (session exists), but the backend - // should implement authorization checks to prevent cross-tenant access - await engine.processRequest(tenant2Request, mockRes); - - // Restoration should work (this test verifies the session CAN be restored) - // Authorization is the backend's responsibility - expect(mockRes.status).not.toHaveBeenCalledWith(404); - - await engine.shutdown(); - }); - }); - - describe('Concurrent Restoration Requests', () => { - it('should handle multiple concurrent restoration requests for same session', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = '550e8400-e29b-41d4-a716-446655440000'; - - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) - }); - - let hookCallCount = 0; - const restorationHook: SessionRestoreHook = async (sid) => { - hookCallCount++; - // Simulate slow database query - await new Promise(resolve => setTimeout(resolve, 50)); - const session = await mockStore.loadSession(sid); - return session ? session.instanceContext : null; - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: restorationHook, - sessionRestorationTimeout: 5000 - }); - - // Simulate 5 concurrent requests with same unknown session ID - const requests = Array.from({ length: 5 }, (_, i) => { - const { req: mockReq, res: mockRes } = createMockReqRes(sessionId, { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: i + 1 - }); - - return engine.processRequest(mockReq, mockRes, context); - }); - - // All should complete without error - await Promise.all(requests); - - // Hook should be called multiple times (no built-in deduplication) - // This is expected - the idempotent session creation prevents duplicates - expect(hookCallCount).toBeGreaterThan(0); - - await engine.shutdown(); - }); - }); - - describe('Database Failure Scenarios', () => { - it('should handle database connection failures gracefully', async () => { - const failingHook: SessionRestoreHook = async () => { - throw new Error('Database connection failed'); - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: failingHook, - sessionRestorationTimeout: 5000 - }); - - const { req: mockReq, res: mockRes } = createMockReqRes('550e8400-e29b-41d4-a716-446655440000'); - - await engine.processRequest(mockReq, mockRes); - - // Should return 500 for database errors - expect(mockRes.status).toHaveBeenCalledWith(500); - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringMatching(/restoration failed|error/i) - }) - }) - ); - - await engine.shutdown(); - }); - - it('should timeout on slow database queries', async () => { - const slowHook: SessionRestoreHook = async () => { - // Simulate very slow database query - await new Promise(resolve => setTimeout(resolve, 10000)); - return { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test' - }; - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: slowHook, - sessionRestorationTimeout: 100 // 100ms timeout - }); - - const { req: mockReq, res: mockRes } = createMockReqRes('550e8400-e29b-41d4-a716-446655440000'); - - await engine.processRequest(mockReq, mockRes); - - // Should return 408 for timeout - expect(mockRes.status).toHaveBeenCalledWith(408); - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringMatching(/timeout|timed out/i) - }) - }) - ); - - await engine.shutdown(); - }); - }); - - describe('Session Metadata Tracking', () => { - it('should track session metadata correctly', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance', - metadata: { - userId: 'user-123', - plan: 'premium' - } - }; - - const sessionId = '550e8400-e29b-41d4-a716-446655440000'; - - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000), - metadata: { - userAgent: 'test-client/1.0', - ip: '192.168.1.1' - } - }); - - const session = await mockStore.loadSession(sessionId); - - expect(session).toBeDefined(); - expect(session?.instanceContext.metadata).toEqual({ - userId: 'user-123', - plan: 'premium' - }); - expect(session?.metadata).toEqual({ - userAgent: 'test-client/1.0', - ip: '192.168.1.1' - }); - }); - - it('should update last access time on restoration', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test-instance' - }; - - const sessionId = '550e8400-e29b-41d4-a716-446655440000'; - const originalLastAccess = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago - - await mockStore.saveSession({ - sessionId, - instanceContext: context, - createdAt: new Date(Date.now() - 20 * 60 * 1000), - lastAccess: originalLastAccess, - expiresAt: new Date(Date.now() + 20 * 60 * 1000) - }); - - // Wait a bit - await new Promise(resolve => setTimeout(resolve, 100)); - - // Load session (simulates restoration) - const session = await mockStore.loadSession(sessionId); - - expect(session).toBeDefined(); - expect(session!.lastAccess.getTime()).toBeGreaterThan(originalLastAccess.getTime()); - }); - }); - - describe('Session Cleanup', () => { - it('should clean up expired sessions', async () => { - // Add multiple sessions with different expiration times - await mockStore.saveSession({ - sessionId: 'session-1', - instanceContext: { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'key1', - instanceId: 'instance-1' - }, - createdAt: new Date(Date.now() - 60 * 60 * 1000), - lastAccess: new Date(Date.now() - 45 * 60 * 1000), - expiresAt: new Date(Date.now() - 15 * 60 * 1000) // Expired - }); - - await mockStore.saveSession({ - sessionId: 'session-2', - instanceContext: { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'key2', - instanceId: 'instance-2' - }, - createdAt: new Date(), - lastAccess: new Date(), - expiresAt: new Date(Date.now() + 30 * 60 * 1000) // Valid - }); - - const cleanedCount = await mockStore.cleanExpired(); - - expect(cleanedCount).toBe(1); - expect(mockStore.getAllSessions().size).toBe(1); - expect(mockStore.getAllSessions().has('session-2')).toBe(true); - expect(mockStore.getAllSessions().has('session-1')).toBe(false); - }); - }); - - describe('Backwards Compatibility', () => { - it('should work without restoration hook (legacy behavior)', async () => { - // Engine without restoration hook should work normally - const engine = new N8NMCPEngine(); - - const sessionInfo = engine.getSessionInfo(); - - expect(sessionInfo).toBeDefined(); - expect(sessionInfo.active).toBeDefined(); - - await engine.shutdown(); - }); - - it('should not break existing session creation flow', async () => { - const engine = new N8NMCPEngine({ - onSessionNotFound: async () => null - }); - - // Creating sessions should work normally - const sessionInfo = engine.getSessionInfo(); - - expect(sessionInfo).toBeDefined(); - - await engine.shutdown(); - }); - }); - - describe('Security Validation', () => { - it('should validate restored context before using it', async () => { - const invalidHook: SessionRestoreHook = async () => { - // Return context with malformed URL (truly invalid) - return { - n8nApiUrl: 'not-a-valid-url', - n8nApiKey: 'test-key', - instanceId: 'test' - } as any; - }; - - const engine = new N8NMCPEngine({ - onSessionNotFound: invalidHook, - sessionRestorationTimeout: 5000 - }); - - const { req: mockReq, res: mockRes } = createMockReqRes('550e8400-e29b-41d4-a716-446655440000'); - - await engine.processRequest(mockReq, mockRes); - - // Should reject invalid context - expect(mockRes.status).toHaveBeenCalledWith(400); - - await engine.shutdown(); - }); - }); -}); diff --git a/tests/integration/session-restoration-warmstart.test.ts b/tests/integration/session-restoration-warmstart.test.ts deleted file mode 100644 index 7d7b850..0000000 --- a/tests/integration/session-restoration-warmstart.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Integration tests for warm start session restoration (v2.19.5) - * - * Tests the simplified warm start pattern where: - * 1. Restoration creates session using existing createSession() flow - * 2. Current request is handled immediately through restored session - * 3. Client auto-retries with initialize on same connection (standard MCP -32000) - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { SingleSessionHTTPServer } from '../../src/http-server-single-session'; -import { InstanceContext } from '../../src/types/instance-context'; -import { SessionRestoreHook } from '../../src/types/session-restoration'; -import type { Request, Response } from 'express'; - -describe('Warm Start Session Restoration Tests', () => { - const TEST_AUTH_TOKEN = 'warmstart-test-token-with-32-chars-min-length'; - let server: SingleSessionHTTPServer; - let originalEnv: NodeJS.ProcessEnv; - - beforeEach(() => { - // Save and set environment - originalEnv = { ...process.env }; - process.env.AUTH_TOKEN = TEST_AUTH_TOKEN; - process.env.PORT = '0'; - process.env.NODE_ENV = 'test'; - }); - - afterEach(async () => { - // Cleanup server - if (server) { - await server.shutdown(); - } - - // Restore environment - process.env = originalEnv; - }); - - // Helper to create mocked Request and Response - function createMockReqRes(sessionId?: string, body?: any) { - const req = { - method: 'POST', - path: '/mcp', - url: '/mcp', - originalUrl: '/mcp', - headers: { - authorization: `Bearer ${TEST_AUTH_TOKEN}`, - ...(sessionId && { 'mcp-session-id': sessionId }) - } as Record, - body: body || { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 1 - }, - ip: '127.0.0.1', - readable: true, - readableEnded: false, - complete: true, - get: vi.fn((header: string) => req.headers[header.toLowerCase()]), - on: vi.fn(), - removeListener: vi.fn() - } as any as Request; - - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - setHeader: vi.fn(), - send: vi.fn().mockReturnThis(), - headersSent: false, - finished: false - } as any as Response; - - return { req, res }; - } - - describe('Happy Path: Successful Restoration', () => { - it('should restore session and handle current request immediately', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'test-instance' - }; - - const sessionId = 'test-session-550e8400'; - let restoredSessionId: string | null = null; - - // Mock restoration hook that returns context - const restorationHook: SessionRestoreHook = async (sid) => { - restoredSessionId = sid; - return context; - }; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: restorationHook, - sessionRestorationTimeout: 5000 - }); - - // Start server - await server.start(); - - // Client sends request with unknown session ID - const { req, res } = createMockReqRes(sessionId); - - // Handle request - await server.handleRequest(req, res, context); - - // Verify restoration hook was called - expect(restoredSessionId).toBe(sessionId); - - // Verify response was handled (not rejected with 400/404) - // A successful restoration should not return these error codes - expect(res.status).not.toHaveBeenCalledWith(400); - expect(res.status).not.toHaveBeenCalledWith(404); - - // Verify a response was sent (either success or -32000 for initialization) - expect(res.json).toHaveBeenCalled(); - }); - - it('should emit onSessionRestored event after successful restoration', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'test-instance' - }; - - const sessionId = 'test-session-550e8400'; - let restoredEventFired = false; - let restoredEventSessionId: string | null = null; - - const restorationHook: SessionRestoreHook = async () => context; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: restorationHook, - sessionEvents: { - onSessionRestored: (sid, ctx) => { - restoredEventFired = true; - restoredEventSessionId = sid; - } - } - }); - - await server.start(); - - const { req, res } = createMockReqRes(sessionId); - await server.handleRequest(req, res, context); - - // Wait for async event - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(restoredEventFired).toBe(true); - expect(restoredEventSessionId).toBe(sessionId); - }); - }); - - describe('Failure Cleanup', () => { - it('should clean up session when restoration fails', async () => { - const sessionId = 'test-session-550e8400'; - - // Mock failing restoration hook - const failingHook: SessionRestoreHook = async () => { - throw new Error('Database connection failed'); - }; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: failingHook, - sessionRestorationTimeout: 5000 - }); - - await server.start(); - - const { req, res } = createMockReqRes(sessionId); - await server.handleRequest(req, res); - - // Verify error response - expect(res.status).toHaveBeenCalledWith(500); - - // Verify session was NOT created (cleanup happened) - const activeSessions = server.getActiveSessions(); - expect(activeSessions).not.toContain(sessionId); - }); - - it('should clean up session when restoration times out', async () => { - const sessionId = 'test-session-550e8400'; - - // Mock slow restoration hook - const slowHook: SessionRestoreHook = async () => { - await new Promise(resolve => setTimeout(resolve, 10000)); // 10 seconds - return { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-key', - instanceId: 'test' - }; - }; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: slowHook, - sessionRestorationTimeout: 100 // 100ms timeout - }); - - await server.start(); - - const { req, res } = createMockReqRes(sessionId); - await server.handleRequest(req, res); - - // Verify timeout response - expect(res.status).toHaveBeenCalledWith(408); - - // Verify session was cleaned up - const activeSessions = server.getActiveSessions(); - expect(activeSessions).not.toContain(sessionId); - }); - - it('should clean up session when restored context is invalid', async () => { - const sessionId = 'test-session-550e8400'; - - // Mock hook returning invalid context - const invalidHook: SessionRestoreHook = async () => { - return { - n8nApiUrl: 'not-a-valid-url', // Invalid URL format - n8nApiKey: 'test-key', - instanceId: 'test' - } as any; - }; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: invalidHook, - sessionRestorationTimeout: 5000 - }); - - await server.start(); - - const { req, res } = createMockReqRes(sessionId); - await server.handleRequest(req, res); - - // Verify validation error response - expect(res.status).toHaveBeenCalledWith(400); - - // Verify session was NOT created - const activeSessions = server.getActiveSessions(); - expect(activeSessions).not.toContain(sessionId); - }); - }); - - describe('Concurrent Idempotency', () => { - it('should handle concurrent restoration attempts for same session idempotently', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'test-instance' - }; - - const sessionId = 'test-session-550e8400'; - let hookCallCount = 0; - - // Mock restoration hook with slow query - const restorationHook: SessionRestoreHook = async () => { - hookCallCount++; - // Simulate slow database query - await new Promise(resolve => setTimeout(resolve, 50)); - return context; - }; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: restorationHook, - sessionRestorationTimeout: 5000 - }); - - await server.start(); - - // Send 5 concurrent requests with same unknown session ID - const requests = Array.from({ length: 5 }, (_, i) => { - const { req, res } = createMockReqRes(sessionId, { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: i + 1 - }); - return server.handleRequest(req, res, context); - }); - - // All should complete without error (no unhandled rejections) - const results = await Promise.allSettled(requests); - - // All requests should complete (either fulfilled or rejected) - expect(results.length).toBe(5); - - // Hook should be called at least once (possibly more for concurrent requests) - expect(hookCallCount).toBeGreaterThan(0); - - // None of the requests should fail with server errors (500) - // They may return -32000 for initialization, but that's expected - results.forEach((result, i) => { - if (result.status === 'rejected') { - // Unexpected rejection - fail the test - throw new Error(`Request ${i} failed unexpectedly: ${result.reason}`); - } - }); - }); - - it('should reuse already-restored session for concurrent requests', async () => { - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'test-instance' - }; - - const sessionId = 'test-session-550e8400'; - let hookCallCount = 0; - - // Track restoration attempts - const restorationHook: SessionRestoreHook = async () => { - hookCallCount++; - return context; - }; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: restorationHook, - sessionRestorationTimeout: 5000 - }); - - await server.start(); - - // First request triggers restoration - const { req: req1, res: res1 } = createMockReqRes(sessionId); - await server.handleRequest(req1, res1, context); - - // Verify hook was called for first request - expect(hookCallCount).toBe(1); - - // Second request with same session ID - const { req: req2, res: res2 } = createMockReqRes(sessionId); - await server.handleRequest(req2, res2, context); - - // If session was reused, hook should not be called again - // (or called again if session wasn't fully initialized yet) - // Either way, both requests should complete without errors - expect(res1.json).toHaveBeenCalled(); - expect(res2.json).toHaveBeenCalled(); - }); - }); - - describe('Restoration Hook Edge Cases', () => { - it('should handle restoration hook returning null (session rejected)', async () => { - const sessionId = 'test-session-550e8400'; - - // Hook explicitly rejects restoration - const rejectingHook: SessionRestoreHook = async () => null; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: rejectingHook, - sessionRestorationTimeout: 5000 - }); - - await server.start(); - - const { req, res } = createMockReqRes(sessionId); - await server.handleRequest(req, res); - - // Verify rejection response - expect(res.status).toHaveBeenCalledWith(400); - - // Verify session was NOT created - expect(server.getActiveSessions()).not.toContain(sessionId); - }); - - it('should handle restoration hook returning undefined (session rejected)', async () => { - const sessionId = 'test-session-550e8400'; - - // Hook returns undefined - const undefinedHook: SessionRestoreHook = async () => undefined as any; - - server = new SingleSessionHTTPServer({ - onSessionNotFound: undefinedHook, - sessionRestorationTimeout: 5000 - }); - - await server.start(); - - const { req, res } = createMockReqRes(sessionId); - await server.handleRequest(req, res); - - // Verify rejection response - expect(res.status).toHaveBeenCalledWith(400); - - // Verify session was NOT created - expect(server.getActiveSessions()).not.toContain(sessionId); - }); - }); -}); diff --git a/tests/integration/session/test-onSessionCreated-event.ts b/tests/integration/session/test-onSessionCreated-event.ts deleted file mode 100644 index 24a564a..0000000 --- a/tests/integration/session/test-onSessionCreated-event.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Test to verify that onSessionCreated event is fired during standard initialize flow - * This test addresses the bug reported in v2.19.0 where the event was not fired - * for sessions created during the initialize request. - */ - -import { SingleSessionHTTPServer } from '../../../src/http-server-single-session'; -import { InstanceContext } from '../../../src/types/instance-context'; - -// Mock environment setup -process.env.AUTH_TOKEN = 'test-token-for-n8n-testing-minimum-32-chars'; -process.env.NODE_ENV = 'test'; -process.env.PORT = '3456'; // Use different port to avoid conflicts - -async function testOnSessionCreatedEvent() { - console.log('\n๐Ÿงช Test: onSessionCreated Event Firing During Initialize\n'); - console.log('โ”'.repeat(60)); - - let eventFired = false; - let capturedSessionId: string | undefined; - let capturedContext: InstanceContext | undefined; - - // Create server with onSessionCreated handler - const server = new SingleSessionHTTPServer({ - sessionEvents: { - onSessionCreated: async (sessionId: string, instanceContext?: InstanceContext) => { - console.log('โœ… onSessionCreated event fired!'); - console.log(` Session ID: ${sessionId}`); - console.log(` Context: ${instanceContext ? 'Present' : 'Not provided'}`); - eventFired = true; - capturedSessionId = sessionId; - capturedContext = instanceContext; - } - } - }); - - try { - // Start the HTTP server - console.log('\n๐Ÿ“ก Starting HTTP server...'); - await server.start(); - console.log('โœ… Server started\n'); - - // Wait a moment for server to be ready - await new Promise(resolve => setTimeout(resolve, 500)); - - // Simulate an MCP initialize request - console.log('๐Ÿ“ค Simulating MCP initialize request...'); - - const port = parseInt(process.env.PORT || '3456'); - const fetch = (await import('node-fetch')).default; - - const response = await fetch(`http://localhost:${port}/mcp`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer test-token-for-n8n-testing-minimum-32-chars', - 'Accept': 'application/json, text/event-stream' - }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { - name: 'test-client', - version: '1.0.0' - } - }, - id: 1 - }) - }); - - const result = await response.json() as any; - - console.log('๐Ÿ“ฅ Response received:', response.status); - console.log(' Response body:', JSON.stringify(result, null, 2)); - - // Wait a moment for event to be processed - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Verify results - console.log('\n๐Ÿ” Verification:'); - console.log('โ”'.repeat(60)); - - if (eventFired) { - console.log('โœ… SUCCESS: onSessionCreated event was fired'); - console.log(` Captured Session ID: ${capturedSessionId}`); - console.log(` Context provided: ${capturedContext !== undefined}`); - - // Verify session is in active sessions list - const activeSessions = server.getActiveSessions(); - console.log(`\n๐Ÿ“Š Active sessions count: ${activeSessions.length}`); - - if (activeSessions.length > 0) { - console.log('โœ… Session registered in active sessions list'); - console.log(` Session IDs: ${activeSessions.join(', ')}`); - } else { - console.log('โŒ No active sessions found'); - } - - // Check if captured session ID is in active sessions - if (capturedSessionId && activeSessions.includes(capturedSessionId)) { - console.log('โœ… Event session ID matches active session'); - } else { - console.log('โš ๏ธ Event session ID not found in active sessions'); - } - - console.log('\n๐ŸŽ‰ TEST PASSED: Bug is fixed!'); - console.log('โ”'.repeat(60)); - - } else { - console.log('โŒ FAILURE: onSessionCreated event was NOT fired'); - console.log('โ”'.repeat(60)); - console.log('\n๐Ÿ’” TEST FAILED: Bug still exists'); - } - - // Cleanup - await server.shutdown(); - - return eventFired; - - } catch (error) { - console.error('\nโŒ Test error:', error); - await server.shutdown(); - return false; - } -} - -// Run the test -testOnSessionCreatedEvent() - .then(success => { - process.exit(success ? 0 : 1); - }) - .catch(error => { - console.error('Unhandled error:', error); - process.exit(1); - }); diff --git a/tests/unit/http-server-session-management.test.ts b/tests/unit/http-server-session-management.test.ts index 3a6b566..d1e4387 100644 --- a/tests/unit/http-server-session-management.test.ts +++ b/tests/unit/http-server-session-management.test.ts @@ -631,16 +631,15 @@ describe('HTTP Server Session Management', () => { describe('Transport Management', () => { it('should handle transport cleanup on close', async () => { server = new SingleSessionHTTPServer(); - - // Test the transport cleanup mechanism by calling removeSession directly + + // Test the transport cleanup mechanism by setting up a transport with onclose const sessionId = 'test-session-id-1234-5678-9012-345678901234'; const mockTransport = { close: vi.fn().mockResolvedValue(undefined), sessionId, - onclose: undefined as (() => void) | undefined, - onerror: undefined as ((error: Error) => void) | undefined + onclose: null as (() => void) | null }; - + (server as any).transports[sessionId] = mockTransport; (server as any).servers[sessionId] = {}; (server as any).sessionMetadata[sessionId] = { @@ -648,16 +647,18 @@ describe('HTTP Server Session Management', () => { createdAt: new Date() }; - // Directly call removeSession to test cleanup behavior - await (server as any).removeSession(sessionId, 'transport_closed'); + // Set up the onclose handler like the real implementation would + mockTransport.onclose = () => { + (server as any).removeSession(sessionId, 'transport_closed'); + }; - // Verify cleanup completed + // Simulate transport close + if (mockTransport.onclose) { + await mockTransport.onclose(); + } + + // Verify cleanup was triggered expect((server as any).transports[sessionId]).toBeUndefined(); - expect((server as any).servers[sessionId]).toBeUndefined(); - expect((server as any).sessionMetadata[sessionId]).toBeUndefined(); - expect(mockTransport.close).toHaveBeenCalled(); - expect(mockTransport.onclose).toBeUndefined(); - expect(mockTransport.onerror).toBeUndefined(); }); it('should handle multiple concurrent sessions', async () => { diff --git a/tests/unit/session-lifecycle-events.test.ts b/tests/unit/session-lifecycle-events.test.ts deleted file mode 100644 index 7022f5d..0000000 --- a/tests/unit/session-lifecycle-events.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Unit tests for Session Lifecycle Events (Phase 3 - REQ-4) - * Tests event emission configuration and error handling - * - * Note: Events are fire-and-forget (non-blocking), so we test: - * 1. Configuration works without errors - * 2. Operations complete successfully even if handlers fail - * 3. Handlers don't block operations - */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { N8NMCPEngine } from '../../src/mcp-engine'; -import { InstanceContext } from '../../src/types/instance-context'; - -describe('Session Lifecycle Events (Phase 3 - REQ-4)', () => { - let engine: N8NMCPEngine; - const testContext: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'test-instance' - }; - - beforeEach(() => { - // Set required AUTH_TOKEN environment variable for testing - process.env.AUTH_TOKEN = 'test-token-for-session-lifecycle-events-testing-32chars'; - }); - - describe('onSessionCreated event', () => { - it('should configure onSessionCreated handler without error', () => { - const onSessionCreated = vi.fn(); - - engine = new N8NMCPEngine({ - sessionEvents: { onSessionCreated } - }); - - const sessionId = 'instance-test-abc123-uuid-created-test-1'; - const result = engine.restoreSession(sessionId, testContext); - - // Session should be created successfully - expect(result).toBe(true); - expect(engine.getActiveSessions()).toContain(sessionId); - }); - - it('should create session successfully even with handler error', () => { - const errorHandler = vi.fn(() => { - throw new Error('Event handler error'); - }); - - engine = new N8NMCPEngine({ - sessionEvents: { onSessionCreated: errorHandler } - }); - - const sessionId = 'instance-test-abc123-uuid-error-test'; - - // Should not throw despite handler error (non-blocking) - expect(() => { - engine.restoreSession(sessionId, testContext); - }).not.toThrow(); - - // Session should still be created successfully - expect(engine.getActiveSessions()).toContain(sessionId); - }); - - it('should support async handlers without blocking', () => { - const asyncHandler = vi.fn(async () => { - await new Promise(resolve => setTimeout(resolve, 100)); - }); - - engine = new N8NMCPEngine({ - sessionEvents: { onSessionCreated: asyncHandler } - }); - - const sessionId = 'instance-test-abc123-uuid-async-test'; - - // Should return immediately (non-blocking) - const startTime = Date.now(); - engine.restoreSession(sessionId, testContext); - const endTime = Date.now(); - - // Should complete quickly (not wait for async handler) - expect(endTime - startTime).toBeLessThan(50); - expect(engine.getActiveSessions()).toContain(sessionId); - }); - }); - - describe('onSessionDeleted event', () => { - it('should configure onSessionDeleted handler without error', () => { - const onSessionDeleted = vi.fn(); - - engine = new N8NMCPEngine({ - sessionEvents: { onSessionDeleted } - }); - - const sessionId = 'instance-test-abc123-uuid-deleted-test'; - - // Create and delete session - engine.restoreSession(sessionId, testContext); - const result = engine.deleteSession(sessionId); - - // Deletion should succeed - expect(result).toBe(true); - expect(engine.getActiveSessions()).not.toContain(sessionId); - }); - - it('should not configure onSessionDeleted for non-existent session', () => { - const onSessionDeleted = vi.fn(); - - engine = new N8NMCPEngine({ - sessionEvents: { onSessionDeleted } - }); - - // Try to delete non-existent session - const result = engine.deleteSession('non-existent-session-id'); - - // Should return false (session not found) - expect(result).toBe(false); - }); - - it('should delete session successfully even with handler error', () => { - const errorHandler = vi.fn(() => { - throw new Error('Deletion event error'); - }); - - engine = new N8NMCPEngine({ - sessionEvents: { onSessionDeleted: errorHandler } - }); - - const sessionId = 'instance-test-abc123-uuid-delete-error-test'; - - // Create session - engine.restoreSession(sessionId, testContext); - - // Delete should succeed despite handler error - const deleted = engine.deleteSession(sessionId); - expect(deleted).toBe(true); - - // Session should still be deleted - expect(engine.getActiveSessions()).not.toContain(sessionId); - }); - }); - - describe('Multiple events configuration', () => { - it('should support multiple events configured together', () => { - const onSessionCreated = vi.fn(); - const onSessionDeleted = vi.fn(); - - engine = new N8NMCPEngine({ - sessionEvents: { - onSessionCreated, - onSessionDeleted - } - }); - - const sessionId = 'instance-test-abc123-uuid-multi-event-test'; - - // Create session - engine.restoreSession(sessionId, testContext); - expect(engine.getActiveSessions()).toContain(sessionId); - - // Delete session - engine.deleteSession(sessionId); - expect(engine.getActiveSessions()).not.toContain(sessionId); - }); - - it('should handle mix of sync and async handlers', () => { - const syncHandler = vi.fn(); - const asyncHandler = vi.fn(async () => { - await new Promise(resolve => setTimeout(resolve, 10)); - }); - - engine = new N8NMCPEngine({ - sessionEvents: { - onSessionCreated: syncHandler, - onSessionDeleted: asyncHandler - } - }); - - const sessionId = 'instance-test-abc123-uuid-mixed-handlers'; - - // Create session - const startTime = Date.now(); - engine.restoreSession(sessionId, testContext); - const createTime = Date.now(); - - // Should not block for async handler - expect(createTime - startTime).toBeLessThan(50); - - // Delete session - engine.deleteSession(sessionId); - const deleteTime = Date.now(); - - // Should not block for async handler - expect(deleteTime - createTime).toBeLessThan(50); - }); - }); - - describe('Event handler error behavior', () => { - it('should not propagate errors from event handlers to caller', () => { - const errorHandler = vi.fn(() => { - throw new Error('Test error'); - }); - - engine = new N8NMCPEngine({ - sessionEvents: { - onSessionCreated: errorHandler - } - }); - - const sessionId = 'instance-test-abc123-uuid-no-propagate'; - - // Should not throw (non-blocking error handling) - expect(() => { - engine.restoreSession(sessionId, testContext); - }).not.toThrow(); - - // Session was created successfully - expect(engine.getActiveSessions()).toContain(sessionId); - }); - - it('should allow operations to complete if event handler fails', () => { - const errorHandler = vi.fn(() => { - throw new Error('Handler error'); - }); - - engine = new N8NMCPEngine({ - sessionEvents: { - onSessionDeleted: errorHandler - } - }); - - const sessionId = 'instance-test-abc123-uuid-continue-on-error'; - - engine.restoreSession(sessionId, testContext); - - // Delete should succeed despite handler error - const result = engine.deleteSession(sessionId); - expect(result).toBe(true); - - // Session should be deleted - expect(engine.getActiveSessions()).not.toContain(sessionId); - }); - }); - - describe('Event handler with metadata', () => { - it('should configure handlers with metadata support', () => { - const onSessionCreated = vi.fn(); - - engine = new N8NMCPEngine({ - sessionEvents: { onSessionCreated } - }); - - const sessionId = 'instance-test-abc123-uuid-metadata-test'; - const contextWithMetadata = { - ...testContext, - metadata: { - userId: 'user-456', - tier: 'enterprise', - region: 'us-east-1' - } - }; - - engine.restoreSession(sessionId, contextWithMetadata); - - // Session created successfully - expect(engine.getActiveSessions()).toContain(sessionId); - - // State includes metadata - const state = engine.getSessionState(sessionId); - expect(state?.metadata).toEqual({ - userId: 'user-456', - tier: 'enterprise', - region: 'us-east-1' - }); - }); - }); - - describe('Configuration validation', () => { - it('should accept empty sessionEvents object', () => { - expect(() => { - engine = new N8NMCPEngine({ - sessionEvents: {} - }); - }).not.toThrow(); - }); - - it('should accept undefined sessionEvents', () => { - expect(() => { - engine = new N8NMCPEngine({ - sessionEvents: undefined - }); - }).not.toThrow(); - }); - - it('should work without sessionEvents configured', () => { - engine = new N8NMCPEngine(); - - const sessionId = 'instance-test-abc123-uuid-no-events'; - - // Should work normally - engine.restoreSession(sessionId, testContext); - expect(engine.getActiveSessions()).toContain(sessionId); - - engine.deleteSession(sessionId); - expect(engine.getActiveSessions()).not.toContain(sessionId); - }); - }); -}); diff --git a/tests/unit/session-management-api.test.ts b/tests/unit/session-management-api.test.ts deleted file mode 100644 index 3d0217d..0000000 --- a/tests/unit/session-management-api.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Unit tests for Session Management API (Phase 2 - REQ-5) - * Tests the public API methods for session management in v2.19.0 - */ -import { describe, it, expect, beforeEach } from 'vitest'; -import { N8NMCPEngine } from '../../src/mcp-engine'; -import { InstanceContext } from '../../src/types/instance-context'; - -describe('Session Management API (Phase 2 - REQ-5)', () => { - let engine: N8NMCPEngine; - const testContext: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'test-instance' - }; - - beforeEach(() => { - // Set required AUTH_TOKEN environment variable for testing - process.env.AUTH_TOKEN = 'test-token-for-session-management-testing-32chars'; - - // Create engine with session restoration disabled for these tests - engine = new N8NMCPEngine({ - sessionTimeout: 30 * 60 * 1000 // 30 minutes - }); - }); - - describe('getActiveSessions()', () => { - it('should return empty array when no sessions exist', () => { - const sessionIds = engine.getActiveSessions(); - expect(sessionIds).toEqual([]); - }); - - it('should return session IDs after session creation via restoreSession', () => { - // Create session using direct API (not through HTTP request) - const sessionId = 'instance-test-abc123-uuid-session-test-1'; - engine.restoreSession(sessionId, testContext); - - const sessionIds = engine.getActiveSessions(); - expect(sessionIds.length).toBe(1); - expect(sessionIds).toContain(sessionId); - }); - - it('should return multiple session IDs when multiple sessions exist', () => { - // Create multiple sessions using direct API - const sessions = [ - { id: 'instance-test1-abc123-uuid-session-1', context: { ...testContext, instanceId: 'instance-1' } }, - { id: 'instance-test2-abc123-uuid-session-2', context: { ...testContext, instanceId: 'instance-2' } } - ]; - - sessions.forEach(({ id, context }) => { - engine.restoreSession(id, context); - }); - - const sessionIds = engine.getActiveSessions(); - expect(sessionIds.length).toBe(2); - expect(sessionIds).toContain(sessions[0].id); - expect(sessionIds).toContain(sessions[1].id); - }); - }); - - describe('getSessionState()', () => { - it('should return null for non-existent session', () => { - const state = engine.getSessionState('non-existent-session-id'); - expect(state).toBeNull(); - }); - - it('should return session state for existing session', () => { - // Create a session using direct API - const sessionId = 'instance-test-abc123-uuid-session-state-test'; - engine.restoreSession(sessionId, testContext); - - const state = engine.getSessionState(sessionId); - expect(state).not.toBeNull(); - expect(state).toMatchObject({ - sessionId: sessionId, - instanceContext: expect.objectContaining({ - n8nApiUrl: testContext.n8nApiUrl, - n8nApiKey: testContext.n8nApiKey, - instanceId: testContext.instanceId - }), - createdAt: expect.any(Date), - lastAccess: expect.any(Date), - expiresAt: expect.any(Date) - }); - }); - - it('should include metadata in session state if available', () => { - const contextWithMetadata: InstanceContext = { - ...testContext, - metadata: { userId: 'user-123', tier: 'premium' } - }; - - const sessionId = 'instance-test-abc123-uuid-metadata-test'; - engine.restoreSession(sessionId, contextWithMetadata); - - const state = engine.getSessionState(sessionId); - - expect(state?.metadata).toEqual({ userId: 'user-123', tier: 'premium' }); - }); - - it('should calculate correct expiration time', () => { - const sessionId = 'instance-test-abc123-uuid-expiry-test'; - engine.restoreSession(sessionId, testContext); - - const state = engine.getSessionState(sessionId); - - expect(state).not.toBeNull(); - if (state) { - const expectedExpiry = new Date(state.lastAccess.getTime() + 30 * 60 * 1000); - const actualExpiry = state.expiresAt; - - // Allow 1 second difference for test timing - expect(Math.abs(actualExpiry.getTime() - expectedExpiry.getTime())).toBeLessThan(1000); - } - }); - }); - - describe('getAllSessionStates()', () => { - it('should return empty array when no sessions exist', () => { - const states = engine.getAllSessionStates(); - expect(states).toEqual([]); - }); - - it('should return all session states', () => { - // Create two sessions using direct API - const session1Id = 'instance-test1-abc123-uuid-all-states-1'; - const session2Id = 'instance-test2-abc123-uuid-all-states-2'; - - engine.restoreSession(session1Id, { - ...testContext, - instanceId: 'instance-1' - }); - - engine.restoreSession(session2Id, { - ...testContext, - instanceId: 'instance-2' - }); - - const states = engine.getAllSessionStates(); - expect(states.length).toBe(2); - expect(states[0]).toMatchObject({ - sessionId: expect.any(String), - instanceContext: expect.objectContaining({ - n8nApiUrl: testContext.n8nApiUrl - }), - createdAt: expect.any(Date), - lastAccess: expect.any(Date), - expiresAt: expect.any(Date) - }); - }); - - it('should filter out sessions without state', () => { - // Create session using direct API - const sessionId = 'instance-test-abc123-uuid-filter-test'; - engine.restoreSession(sessionId, testContext); - - // Get states - const states = engine.getAllSessionStates(); - expect(states.length).toBe(1); - - // All returned states should be non-null - states.forEach(state => { - expect(state).not.toBeNull(); - }); - }); - }); - - describe('restoreSession()', () => { - it('should create a new session with provided ID and context', () => { - const sessionId = 'instance-test-abc123-uuid-test-session-id'; - const result = engine.restoreSession(sessionId, testContext); - - expect(result).toBe(true); - expect(engine.getActiveSessions()).toContain(sessionId); - }); - - it('should be idempotent - return true for existing session', () => { - const sessionId = 'instance-test-abc123-uuid-test-session-id2'; - - // First restoration - const result1 = engine.restoreSession(sessionId, testContext); - expect(result1).toBe(true); - - // Second restoration with same ID - const result2 = engine.restoreSession(sessionId, testContext); - expect(result2).toBe(true); - - // Should still only have one session - const sessionIds = engine.getActiveSessions(); - expect(sessionIds.filter(id => id === sessionId).length).toBe(1); - }); - - it('should return false for invalid session ID format', () => { - const invalidSessionIds = [ - '', // Empty string - 'a'.repeat(101), // Too long (101 chars, exceeds max) - "'; DROP TABLE sessions--", // SQL injection attempt (invalid characters: ', ;, space) - '../../../etc/passwd', // Path traversal attempt (invalid characters: ., /) - 'has spaces here', // Invalid character (space) - 'special@chars#here' // Invalid characters (@, #) - ]; - - invalidSessionIds.forEach(sessionId => { - const result = engine.restoreSession(sessionId, testContext); - expect(result).toBe(false); - }); - }); - - it('should accept short session IDs (relaxed for MCP proxy compatibility)', () => { - const validShortIds = [ - 'short', // 5 chars - now valid - 'a', // 1 char - now valid - 'only-nineteen-chars', // 19 chars - now valid - '12345' // 5 digit ID - now valid - ]; - - validShortIds.forEach(sessionId => { - const result = engine.restoreSession(sessionId, testContext); - expect(result).toBe(true); - expect(engine.getActiveSessions()).toContain(sessionId); - }); - }); - - it('should return false for invalid instance context', () => { - const sessionId = 'instance-test-abc123-uuid-test-session-id3'; - const invalidContext = { - n8nApiUrl: 'not-a-valid-url', // Invalid URL - n8nApiKey: 'test-key', - instanceId: 'test' - } as any; - - const result = engine.restoreSession(sessionId, invalidContext); - expect(result).toBe(false); - }); - - it('should create session that can be retrieved with getSessionState', () => { - const sessionId = 'instance-test-abc123-uuid-test-session-id4'; - engine.restoreSession(sessionId, testContext); - - const state = engine.getSessionState(sessionId); - expect(state).not.toBeNull(); - expect(state?.sessionId).toBe(sessionId); - expect(state?.instanceContext).toEqual(testContext); - }); - }); - - describe('deleteSession()', () => { - it('should return false for non-existent session', () => { - const result = engine.deleteSession('non-existent-session-id'); - expect(result).toBe(false); - }); - - it('should delete existing session and return true', () => { - // Create a session using direct API - const sessionId = 'instance-test-abc123-uuid-delete-test'; - engine.restoreSession(sessionId, testContext); - - // Delete the session - const result = engine.deleteSession(sessionId); - expect(result).toBe(true); - - // Session should no longer exist - expect(engine.getActiveSessions()).not.toContain(sessionId); - expect(engine.getSessionState(sessionId)).toBeNull(); - }); - - it('should return false when trying to delete already deleted session', () => { - // Create and delete session using direct API - const sessionId = 'instance-test-abc123-uuid-double-delete-test'; - engine.restoreSession(sessionId, testContext); - - engine.deleteSession(sessionId); - - // Try to delete again - const result = engine.deleteSession(sessionId); - expect(result).toBe(false); - }); - }); - - describe('Integration workflows', () => { - it('should support periodic backup workflow', () => { - // Create multiple sessions using direct API - for (let i = 0; i < 3; i++) { - const sessionId = `instance-test${i}-abc123-uuid-backup-${i}`; - engine.restoreSession(sessionId, { - ...testContext, - instanceId: `instance-${i}` - }); - } - - // Simulate periodic backup - const states = engine.getAllSessionStates(); - expect(states.length).toBe(3); - - // Each state should be serializable - states.forEach(state => { - const serialized = JSON.stringify(state); - expect(serialized).toBeTruthy(); - - const deserialized = JSON.parse(serialized); - expect(deserialized.sessionId).toBe(state.sessionId); - }); - }); - - it('should support bulk restore workflow', () => { - const sessionData = [ - { sessionId: 'instance-test1-abc123-uuid-bulk-session-1', context: { ...testContext, instanceId: 'user-1' } }, - { sessionId: 'instance-test2-abc123-uuid-bulk-session-2', context: { ...testContext, instanceId: 'user-2' } }, - { sessionId: 'instance-test3-abc123-uuid-bulk-session-3', context: { ...testContext, instanceId: 'user-3' } } - ]; - - // Restore all sessions - for (const { sessionId, context } of sessionData) { - const restored = engine.restoreSession(sessionId, context); - expect(restored).toBe(true); - } - - // Verify all sessions exist - const sessionIds = engine.getActiveSessions(); - expect(sessionIds.length).toBe(3); - - sessionData.forEach(({ sessionId }) => { - expect(sessionIds).toContain(sessionId); - }); - }); - - it('should support session lifecycle workflow (create โ†’ get โ†’ delete)', () => { - // 1. Create session using direct API - const sessionId = 'instance-test-abc123-uuid-lifecycle-test'; - engine.restoreSession(sessionId, testContext); - - // 2. Get session state - const state = engine.getSessionState(sessionId); - expect(state).not.toBeNull(); - - // 3. Simulate saving to database (serialization test) - const serialized = JSON.stringify(state); - expect(serialized).toBeTruthy(); - - // 4. Delete session - const deleted = engine.deleteSession(sessionId); - expect(deleted).toBe(true); - - // 5. Verify deletion - expect(engine.getSessionState(sessionId)).toBeNull(); - expect(engine.getActiveSessions()).not.toContain(sessionId); - }); - }); -}); diff --git a/tests/unit/session-restoration-retry.test.ts b/tests/unit/session-restoration-retry.test.ts deleted file mode 100644 index eef3fc7..0000000 --- a/tests/unit/session-restoration-retry.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -/** - * Unit tests for Session Restoration Retry Policy (Phase 4 - REQ-7) - * Tests retry logic for failed session restoration attempts - */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { N8NMCPEngine } from '../../src/mcp-engine'; -import { InstanceContext } from '../../src/types/instance-context'; - -describe('Session Restoration Retry Policy (Phase 4 - REQ-7)', () => { - const testContext: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'test-instance' - }; - - beforeEach(() => { - // Set required AUTH_TOKEN environment variable for testing - process.env.AUTH_TOKEN = 'test-token-for-session-restoration-retry-testing-32chars'; - vi.clearAllMocks(); - }); - - describe('Default behavior (no retries)', () => { - it('should have 0 retries by default (opt-in)', async () => { - let callCount = 0; - const failingHook = vi.fn(async () => { - callCount++; - throw new Error('Database connection failed'); - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: failingHook - // No sessionRestorationRetries specified - should default to 0 - }); - - // Note: Testing retry behavior requires HTTP request simulation - // This is tested in integration tests - // Here we verify configuration is accepted - - expect(() => { - const sessionId = 'instance-test-abc123-uuid-default-retry'; - engine.restoreSession(sessionId, testContext); - }).not.toThrow(); - }); - - it('should throw immediately on error with 0 retries', () => { - const failingHook = vi.fn(async () => { - throw new Error('Test error'); - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: failingHook, - sessionRestorationRetries: 0 // Explicit 0 retries - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); - - describe('Retry configuration', () => { - it('should accept custom retry count', () => { - const hook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: hook, - sessionRestorationRetries: 3 - }); - - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should accept custom retry delay', () => { - const hook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: hook, - sessionRestorationRetries: 2, - sessionRestorationRetryDelay: 200 // 200ms delay - }); - - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should use default delay of 100ms if not specified', () => { - const hook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: hook, - sessionRestorationRetries: 2 - // sessionRestorationRetryDelay not specified - should default to 100ms - }); - - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); - - describe('Error classification', () => { - it('should configure retry for transient errors', () => { - let attemptCount = 0; - const failTwiceThenSucceed = vi.fn(async () => { - attemptCount++; - if (attemptCount < 3) { - throw new Error('Transient error'); - } - return testContext; - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: failTwiceThenSucceed, - sessionRestorationRetries: 3 - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should not configure retry for timeout errors', () => { - const timeoutHook = vi.fn(async () => { - const error = new Error('Timeout error'); - error.name = 'TimeoutError'; - throw error; - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: timeoutHook, - sessionRestorationRetries: 3, - sessionRestorationTimeout: 100 - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); - - describe('Timeout interaction', () => { - it('should configure overall timeout for all retry attempts', () => { - const slowHook = vi.fn(async () => { - await new Promise(resolve => setTimeout(resolve, 200)); - return testContext; - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: slowHook, - sessionRestorationRetries: 3, - sessionRestorationTimeout: 500 // 500ms total for all attempts - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should use default timeout of 5000ms if not specified', () => { - const hook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: hook, - sessionRestorationRetries: 2 - // sessionRestorationTimeout not specified - should default to 5000ms - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); - - describe('Success scenarios', () => { - it('should succeed on first attempt if hook succeeds', () => { - const successHook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: successHook, - sessionRestorationRetries: 3 - }); - - // Should succeed - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should succeed after retry if hook eventually succeeds', () => { - let attemptCount = 0; - const retryThenSucceed = vi.fn(async () => { - attemptCount++; - if (attemptCount === 1) { - throw new Error('First attempt failed'); - } - return testContext; - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: retryThenSucceed, - sessionRestorationRetries: 2 - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); - - describe('Hook validation', () => { - it('should validate context returned by hook after retry', () => { - let attemptCount = 0; - const invalidAfterRetry = vi.fn(async () => { - attemptCount++; - if (attemptCount === 1) { - throw new Error('First attempt failed'); - } - // Return invalid context after retry - return { - n8nApiUrl: 'not-a-valid-url', // Invalid URL - n8nApiKey: 'test-key', - instanceId: 'test' - } as any; - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: invalidAfterRetry, - sessionRestorationRetries: 2 - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should handle null return from hook after retry', () => { - let attemptCount = 0; - const nullAfterRetry = vi.fn(async () => { - attemptCount++; - if (attemptCount === 1) { - throw new Error('First attempt failed'); - } - return null; // Session not found after retry - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: nullAfterRetry, - sessionRestorationRetries: 2 - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); - - describe('Edge cases', () => { - it('should handle exactly max retries configuration', () => { - let attemptCount = 0; - const failExactlyMaxTimes = vi.fn(async () => { - attemptCount++; - if (attemptCount <= 2) { - throw new Error('Failing'); - } - return testContext; - }); - - const engine = new N8NMCPEngine({ - onSessionNotFound: failExactlyMaxTimes, - sessionRestorationRetries: 2 // Will succeed on 3rd attempt (0, 1, 2 retries) - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should handle zero delay between retries', () => { - const hook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: hook, - sessionRestorationRetries: 3, - sessionRestorationRetryDelay: 0 // No delay - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should handle very short timeout', () => { - const hook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: hook, - sessionRestorationRetries: 3, - sessionRestorationTimeout: 1 // 1ms timeout - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); - - describe('Integration with lifecycle events', () => { - it('should emit onSessionRestored after successful retry', () => { - let attemptCount = 0; - const retryThenSucceed = vi.fn(async () => { - attemptCount++; - if (attemptCount === 1) { - throw new Error('First attempt failed'); - } - return testContext; - }); - - const onSessionRestored = vi.fn(); - - const engine = new N8NMCPEngine({ - onSessionNotFound: retryThenSucceed, - sessionRestorationRetries: 2, - sessionEvents: { - onSessionRestored - } - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should not emit events if all retries fail', () => { - const alwaysFail = vi.fn(async () => { - throw new Error('Always fails'); - }); - - const onSessionRestored = vi.fn(); - - const engine = new N8NMCPEngine({ - onSessionNotFound: alwaysFail, - sessionRestorationRetries: 2, - sessionEvents: { - onSessionRestored - } - }); - - // Configuration accepted - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); - - describe('Backward compatibility', () => { - it('should work without retry configuration (backward compatible)', () => { - const hook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: hook - // No retry configuration - should work as before - }); - - // Should work - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - - it('should work with only restoration hook configured', () => { - const hook = vi.fn(async () => testContext); - - const engine = new N8NMCPEngine({ - onSessionNotFound: hook, - sessionRestorationTimeout: 5000 - // No retry configuration - }); - - // Should work - expect(() => { - engine.restoreSession('test-session', testContext); - }).not.toThrow(); - }); - }); -}); diff --git a/tests/unit/session-restoration.test.ts b/tests/unit/session-restoration.test.ts deleted file mode 100644 index 6835862..0000000 --- a/tests/unit/session-restoration.test.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { SingleSessionHTTPServer } from '../../src/http-server-single-session'; -import { InstanceContext } from '../../src/types/instance-context'; -import { SessionRestoreHook } from '../../src/types/session-restoration'; - -// Mock dependencies -vi.mock('../../src/utils/logger', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn() - } -})); - -vi.mock('dotenv'); - -// Mock UUID generation to make tests predictable -vi.mock('uuid', () => ({ - v4: vi.fn(() => 'test-session-id-1234-5678-9012-345678901234') -})); - -// Mock transport -vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ - StreamableHTTPServerTransport: vi.fn().mockImplementation((options: any) => { - const mockTransport = { - handleRequest: vi.fn().mockImplementation(async (req: any, res: any, body?: any) => { - if (body && body.method === 'initialize') { - res.setHeader('Mcp-Session-Id', mockTransport.sessionId || 'test-session-id'); - } - res.status(200).json({ - jsonrpc: '2.0', - result: { success: true }, - id: body?.id || 1 - }); - }), - close: vi.fn().mockResolvedValue(undefined), - sessionId: null as string | null, - onclose: null as (() => void) | null - }; - - if (options?.sessionIdGenerator) { - const sessionId = options.sessionIdGenerator(); - mockTransport.sessionId = sessionId; - - if (options.onsessioninitialized) { - setTimeout(() => { - options.onsessioninitialized(sessionId); - }, 0); - } - } - - return mockTransport; - }) -})); - -vi.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({ - SSEServerTransport: vi.fn().mockImplementation(() => ({ - close: vi.fn().mockResolvedValue(undefined) - })) -})); - -vi.mock('../../src/mcp/server', () => { - class MockN8NDocumentationMCPServer { - connect = vi.fn().mockResolvedValue(undefined); - } - return { - N8NDocumentationMCPServer: MockN8NDocumentationMCPServer - }; -}); - -const mockConsoleManager = { - wrapOperation: vi.fn().mockImplementation(async (fn: () => Promise) => { - return await fn(); - }) -}; - -vi.mock('../../src/utils/console-manager', () => ({ - ConsoleManager: vi.fn(() => mockConsoleManager) -})); - -vi.mock('../../src/utils/url-detector', () => ({ - getStartupBaseUrl: vi.fn((host: string, port: number) => `http://localhost:${port || 3000}`), - formatEndpointUrls: vi.fn((baseUrl: string) => ({ - health: `${baseUrl}/health`, - mcp: `${baseUrl}/mcp` - })), - detectBaseUrl: vi.fn((req: any, host: string, port: number) => `http://localhost:${port || 3000}`) -})); - -vi.mock('../../src/utils/version', () => ({ - PROJECT_VERSION: '2.19.0' -})); - -vi.mock('@modelcontextprotocol/sdk/types.js', () => ({ - isInitializeRequest: vi.fn((request: any) => { - return request && request.method === 'initialize'; - }) -})); - -// Create handlers storage for Express mock -const mockHandlers: { [key: string]: any[] } = { - get: [], - post: [], - delete: [], - use: [] -}; - -// Mock Express -vi.mock('express', () => { - const mockExpressApp = { - get: vi.fn((path: string, ...handlers: any[]) => { - mockHandlers.get.push({ path, handlers }); - return mockExpressApp; - }), - post: vi.fn((path: string, ...handlers: any[]) => { - mockHandlers.post.push({ path, handlers }); - return mockExpressApp; - }), - delete: vi.fn((path: string, ...handlers: any[]) => { - mockHandlers.delete.push({ path, handlers }); - return mockExpressApp; - }), - use: vi.fn((handler: any) => { - mockHandlers.use.push(handler); - return mockExpressApp; - }), - set: vi.fn(), - listen: vi.fn((port: number, host: string, callback?: () => void) => { - if (callback) callback(); - return { - on: vi.fn(), - close: vi.fn((cb: () => void) => cb()), - address: () => ({ port: 3000 }) - }; - }) - }; - - interface ExpressMock { - (): typeof mockExpressApp; - json(): (req: any, res: any, next: any) => void; - } - - const expressMock = vi.fn(() => mockExpressApp) as unknown as ExpressMock; - expressMock.json = vi.fn(() => (req: any, res: any, next: any) => { - req.body = req.body || {}; - next(); - }); - - return { - default: expressMock, - Request: {}, - Response: {}, - NextFunction: {} - }; -}); - -describe('Session Restoration (Phase 1 - REQ-1, REQ-2, REQ-8)', () => { - const originalEnv = process.env; - const TEST_AUTH_TOKEN = 'test-auth-token-with-more-than-32-characters'; - let server: SingleSessionHTTPServer; - let consoleLogSpy: any; - let consoleWarnSpy: any; - let consoleErrorSpy: any; - - beforeEach(() => { - // Reset environment - process.env = { ...originalEnv }; - process.env.AUTH_TOKEN = TEST_AUTH_TOKEN; - process.env.PORT = '0'; - process.env.NODE_ENV = 'test'; - - // Mock console methods - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - // Clear all mocks and handlers - vi.clearAllMocks(); - mockHandlers.get = []; - mockHandlers.post = []; - mockHandlers.delete = []; - mockHandlers.use = []; - }); - - afterEach(async () => { - // Restore environment - process.env = originalEnv; - - // Restore console methods - consoleLogSpy.mockRestore(); - consoleWarnSpy.mockRestore(); - consoleErrorSpy.mockRestore(); - - // Shutdown server if running - if (server) { - await server.shutdown(); - server = null as any; - } - }); - - // Helper functions - function findHandler(method: 'get' | 'post' | 'delete', path: string) { - const routes = mockHandlers[method]; - const route = routes.find(r => r.path === path); - return route ? route.handlers[route.handlers.length - 1] : null; - } - - function createMockReqRes() { - const headers: { [key: string]: string } = {}; - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - send: vi.fn().mockReturnThis(), - setHeader: vi.fn((key: string, value: string) => { - headers[key.toLowerCase()] = value; - }), - sendStatus: vi.fn().mockReturnThis(), - headersSent: false, - finished: false, - statusCode: 200, - getHeader: (key: string) => headers[key.toLowerCase()], - headers - }; - - const req = { - method: 'POST', - path: '/mcp', - url: '/mcp', - originalUrl: '/mcp', - headers: {} as Record, - body: {}, - ip: '127.0.0.1', - readable: true, - readableEnded: false, - complete: true, - get: vi.fn((header: string) => (req.headers as Record)[header.toLowerCase()]) - }; - - return { req, res }; - } - - describe('REQ-8: Security-Hardened Session ID Validation', () => { - it('should accept valid UUIDv4 session IDs', () => { - server = new SingleSessionHTTPServer(); - - const validUUIDs = [ - '550e8400-e29b-41d4-a716-446655440000', - 'f47ac10b-58cc-4372-a567-0e02b2c3d479', - 'a1b2c3d4-e5f6-4789-abcd-1234567890ab' - ]; - - for (const sessionId of validUUIDs) { - expect((server as any).isValidSessionId(sessionId)).toBe(true); - } - }); - - it('should accept multi-tenant instance session IDs', () => { - server = new SingleSessionHTTPServer(); - - const multiTenantIds = [ - 'instance-user123-abc-550e8400-e29b-41d4-a716-446655440000', - 'instance-tenant456-xyz-f47ac10b-58cc-4372-a567-0e02b2c3d479' - ]; - - for (const sessionId of multiTenantIds) { - expect((server as any).isValidSessionId(sessionId)).toBe(true); - } - }); - - it('should reject session IDs with SQL injection patterns', () => { - server = new SingleSessionHTTPServer(); - - const sqlInjectionIds = [ - "'; DROP TABLE sessions; --", - "1' OR '1'='1", - "admin'--", - "1'; DELETE FROM sessions WHERE '1'='1" - ]; - - for (const sessionId of sqlInjectionIds) { - expect((server as any).isValidSessionId(sessionId)).toBe(false); - } - }); - - it('should reject session IDs with NoSQL injection patterns', () => { - server = new SingleSessionHTTPServer(); - - const nosqlInjectionIds = [ - '{"$ne": null}', - '{"$gt": ""}', - '{$where: "1==1"}', - '[$regex]' - ]; - - for (const sessionId of nosqlInjectionIds) { - expect((server as any).isValidSessionId(sessionId)).toBe(false); - } - }); - - it('should reject session IDs with path traversal attempts', () => { - server = new SingleSessionHTTPServer(); - - const pathTraversalIds = [ - '../../../etc/passwd', - '..\\..\\..\\windows\\system32', - 'session/../admin', - 'session/./../../config' - ]; - - for (const sessionId of pathTraversalIds) { - expect((server as any).isValidSessionId(sessionId)).toBe(false); - } - }); - - it('should accept short session IDs (relaxed for MCP proxy compatibility)', () => { - server = new SingleSessionHTTPServer(); - - // Short session IDs are now accepted for MCP proxy compatibility - // Security is maintained via character whitelist and max length - const shortIds = [ - 'a', - 'ab', - '123', - '12345', - 'short-id' - ]; - - for (const sessionId of shortIds) { - expect((server as any).isValidSessionId(sessionId)).toBe(true); - } - }); - - it('should reject session IDs that are too long (DoS protection)', () => { - server = new SingleSessionHTTPServer(); - - const tooLongId = 'a'.repeat(101); // Maximum is 100 chars - expect((server as any).isValidSessionId(tooLongId)).toBe(false); - }); - - it('should reject empty or null session IDs', () => { - server = new SingleSessionHTTPServer(); - - expect((server as any).isValidSessionId('')).toBe(false); - expect((server as any).isValidSessionId(null)).toBe(false); - expect((server as any).isValidSessionId(undefined)).toBe(false); - }); - - it('should reject session IDs with special characters', () => { - server = new SingleSessionHTTPServer(); - - const specialCharIds = [ - 'session', - 'session!@#$%^&*()', - 'session\x00null-byte', - 'session\r\nnewline' - ]; - - for (const sessionId of specialCharIds) { - expect((server as any).isValidSessionId(sessionId)).toBe(false); - } - }); - }); - - describe('REQ-2: Idempotent Session Creation', () => { - it('should return same session ID for multiple concurrent createSession calls', async () => { - const mockContext: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'tenant-123' - }; - - server = new SingleSessionHTTPServer(); - - const sessionId = 'instance-tenant123-abc-550e8400-e29b-41d4-a716-446655440000'; - - // Call createSession multiple times with same session ID - const id1 = (server as any).createSession(mockContext, sessionId); - const id2 = (server as any).createSession(mockContext, sessionId); - const id3 = (server as any).createSession(mockContext, sessionId); - - // All calls should return the same session ID (idempotent) - expect(id1).toBe(sessionId); - expect(id2).toBe(sessionId); - expect(id3).toBe(sessionId); - - // NOTE: Transport creation is async via callback - tested in integration tests - }); - - it('should skip session creation if session already exists', async () => { - const mockContext: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'tenant-123' - }; - - server = new SingleSessionHTTPServer(); - - const sessionId = '550e8400-e29b-41d4-a716-446655440000'; - - // Create session first time - (server as any).createSession(mockContext, sessionId); - const transport1 = (server as any).transports[sessionId]; - - // Try to create again - (server as any).createSession(mockContext, sessionId); - const transport2 = (server as any).transports[sessionId]; - - // Should be the same transport instance - expect(transport1).toBe(transport2); - }); - - it('should validate session ID format when provided externally', async () => { - const mockContext: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'tenant-123' - }; - - server = new SingleSessionHTTPServer(); - - const invalidSessionId = "'; DROP TABLE sessions; --"; - - expect(() => { - (server as any).createSession(mockContext, invalidSessionId); - }).toThrow('Invalid session ID format'); - }); - }); - - describe('REQ-1: Session Restoration Hook Configuration', () => { - it('should store restoration hook when provided', () => { - const mockHook: SessionRestoreHook = vi.fn().mockResolvedValue({ - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'tenant-123' - }); - - server = new SingleSessionHTTPServer({ - onSessionNotFound: mockHook, - sessionRestorationTimeout: 5000 - }); - - // Verify hook is stored - expect((server as any).onSessionNotFound).toBe(mockHook); - expect((server as any).sessionRestorationTimeout).toBe(5000); - }); - - it('should work without restoration hook (backward compatible)', () => { - server = new SingleSessionHTTPServer(); - - // Verify hook is not configured - expect((server as any).onSessionNotFound).toBeUndefined(); - }); - - // NOTE: Full restoration flow tests (success, failure, timeout, validation) - // are in tests/integration/session-persistence.test.ts which tests the complete - // end-to-end flow with real HTTP requests - }); - - describe('Backwards Compatibility', () => { - it('should use default timeout when not specified', () => { - server = new SingleSessionHTTPServer({ - onSessionNotFound: vi.fn() - }); - - expect((server as any).sessionRestorationTimeout).toBe(5000); - }); - - it('should use custom timeout when specified', () => { - server = new SingleSessionHTTPServer({ - onSessionNotFound: vi.fn(), - sessionRestorationTimeout: 10000 - }); - - expect((server as any).sessionRestorationTimeout).toBe(10000); - }); - - it('should work without any restoration options', () => { - server = new SingleSessionHTTPServer(); - - expect((server as any).onSessionNotFound).toBeUndefined(); - expect((server as any).sessionRestorationTimeout).toBe(5000); - }); - }); - - describe('Timeout Utility Method', () => { - it('should reject after specified timeout', async () => { - server = new SingleSessionHTTPServer(); - - const timeoutPromise = (server as any).timeout(100); - - await expect(timeoutPromise).rejects.toThrow('Operation timed out after 100ms'); - }); - - it('should create TimeoutError', async () => { - server = new SingleSessionHTTPServer(); - - try { - await (server as any).timeout(50); - expect.fail('Should have thrown TimeoutError'); - } catch (error: any) { - expect(error.name).toBe('TimeoutError'); - expect(error.message).toContain('timed out'); - } - }); - }); - - describe('Session ID Generation', () => { - it('should generate valid session IDs', () => { - // Set environment for multi-tenant mode - process.env.ENABLE_MULTI_TENANT = 'true'; - process.env.MULTI_TENANT_SESSION_STRATEGY = 'instance'; - - server = new SingleSessionHTTPServer(); - - const context: InstanceContext = { - n8nApiUrl: 'https://test.n8n.cloud', - n8nApiKey: 'test-api-key', - instanceId: 'tenant-123' - }; - - const sessionId = (server as any).generateSessionId(context); - - // Should generate instance-prefixed ID in multi-tenant mode - expect(sessionId).toContain('instance-'); - expect((server as any).isValidSessionId(sessionId)).toBe(true); - - // Clean up env - delete process.env.ENABLE_MULTI_TENANT; - delete process.env.MULTI_TENANT_SESSION_STRATEGY; - }); - - it('should generate standard UUIDs when not in multi-tenant mode', () => { - // Ensure multi-tenant mode is disabled - delete process.env.ENABLE_MULTI_TENANT; - - server = new SingleSessionHTTPServer(); - - const sessionId = (server as any).generateSessionId(); - - // Should be a UUID format (mocked in tests but should be non-empty string with hyphens) - expect(sessionId).toBeTruthy(); - expect(typeof sessionId).toBe('string'); - expect(sessionId.length).toBeGreaterThan(20); // At minimum should be longer than minimum session ID length - expect(sessionId).toContain('-'); - - // NOTE: In tests, UUID is mocked so it may not pass strict validation - // In production, generateSessionId uses real uuid.v4() which generates valid UUIDs - }); - }); -});