Files
n8n-mcp/IMPLEMENTATION_GUIDE.md
czlonkowski 1d34ad81d5 feat: implement session persistence for v2.19.0 (Phase 1 + Phase 2)
Phase 1 - Lazy Session Restoration (REQ-1, REQ-2, REQ-8):
- Add onSessionNotFound hook for restoring sessions from external storage
- Implement idempotent session creation to prevent race conditions
- Add session ID validation for security (prevent injection attacks)
- Comprehensive error handling (400/408/500 status codes)
- 13 integration tests covering all scenarios

Phase 2 - Session Management API (REQ-5):
- getActiveSessions(): Get all active session IDs
- getSessionState(sessionId): Get session state for persistence
- getAllSessionStates(): Bulk session state retrieval
- restoreSession(sessionId, context): Manual session restoration
- deleteSession(sessionId): Manual session termination
- 21 unit tests covering all API methods

Benefits:
- Sessions survive container restarts
- Horizontal scaling support (no session stickiness needed)
- Zero-downtime deployments
- 100% backwards compatible

Implementation Details:
- Backend methods in http-server-single-session.ts
- Public API methods in mcp-engine.ts
- SessionState type exported from index.ts
- Synchronous session creation and deletion for reliable testing
- Version updated from 2.18.10 to 2.19.0

Tests: 34 passing (13 integration + 21 unit)
Coverage: Full API coverage with edge cases
Security: Session ID validation prevents SQL/NoSQL injection and path traversal

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 17:25:38 +02:00

81 KiB

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
  2. Phase 0: Environment Setup (Day 0)
  3. Phase 1: Backend Implementation (Days 1-4)
  4. Phase 2: Frontend Implementation (Days 5-9)
  5. Phase 3: Testing & Launch (Days 10-12)
  6. Troubleshooting
  7. 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

# 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:

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:

# 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:

# 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:

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:

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)

-- 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

npx supabase db push

Verification:

-- 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

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:

// 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:

npm test -- src/services/encryption.test.ts

2.2 Create Supabase Client Service

File: src/services/database.ts

import { createClient } from '@supabase/supabase-js';

// Singleton pattern for Supabase client
let supabaseClient: ReturnType<typeof createClient> | 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

interface RateLimitCounter {
  count: number;
  windowStart: number;
}

export class RateLimiter {
  private counters = new Map<string, RateLimitCounter>();
  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:

// 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

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<string, SessionData>();
  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

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<string, CacheEntry>();

// 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<UserContext> {
  // 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)

// 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<Response> {
  // 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<Response> {
  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:

{
  "dependencies": {
    "@supabase/supabase-js": "^2.39.0",
    "bcryptjs": "^2.4.3"
  },
  "devDependencies": {
    "@types/bcryptjs": "^2.4.6"
  }
}

Install dependencies:

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

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

# 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

#!/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:

chmod +x scripts/deploy.sh

4.5 Testing Multi-Tenant Locally

Create test script: scripts/test-multi-tenant.sh

#!/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:

cd ../n8n-mcp-landing
npm install @supabase/ssr @supabase/supabase-js

Create environment file: .env.local

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

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

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

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

'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 (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-md space-y-8 p-8">
        <div>
          <h2 className="text-3xl font-bold">Sign up for n8n-mcp</h2>
          <p className="mt-2 text-sm text-gray-600">
            Join 471 users already building AI workflows
          </p>
        </div>

        <form onSubmit={handleSignup} className="space-y-6">
          <div>
            <Label htmlFor="fullName">Full Name</Label>
            <Input
              id="fullName"
              type="text"
              required
              value={fullName}
              onChange={(e) => setFullName(e.target.value)}
            />
          </div>

          <div>
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              required
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </div>

          <div>
            <Label htmlFor="password">Password</Label>
            <Input
              id="password"
              type="password"
              required
              minLength={8}
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
            <p className="mt-1 text-xs text-gray-500">
              Must be at least 8 characters
            </p>
          </div>

          {error && (
            <div className="rounded-md bg-red-50 p-4 text-sm text-red-800">
              {error}
            </div>
          )}

          {message && (
            <div className="rounded-md bg-green-50 p-4 text-sm text-green-800">
              {message}
            </div>
          )}

          <Button type="submit" className="w-full" disabled={loading}>
            {loading ? 'Signing up...' : 'Sign up'}
          </Button>
        </form>

        <div className="text-center text-sm">
          Already have an account?{' '}
          <a href="/login" className="font-medium text-blue-600 hover:underline">
            Log in
          </a>
        </div>
      </div>
    </div>
  );
}

File: src/app/(auth)/login/page.tsx

'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 (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-md space-y-8 p-8">
        <div>
          <h2 className="text-3xl font-bold">Welcome back</h2>
          <p className="mt-2 text-sm text-gray-600">
            Log in to access your n8n-mcp dashboard
          </p>
        </div>

        <form onSubmit={handleLogin} className="space-y-6">
          <div>
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              required
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </div>

          <div>
            <Label htmlFor="password">Password</Label>
            <Input
              id="password"
              type="password"
              required
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
          </div>

          {error && (
            <div className="rounded-md bg-red-50 p-4 text-sm text-red-800">
              {error}
            </div>
          )}

          <Button type="submit" className="w-full" disabled={loading}>
            {loading ? 'Logging in...' : 'Log in'}
          </Button>
        </form>

        <div className="text-center text-sm">
          Don't have an account?{' '}
          <a href="/signup" className="font-medium text-blue-600 hover:underline">
            Sign up
          </a>
        </div>
      </div>
    </div>
  );
}

File: src/app/auth/callback/route.ts

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

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 (
    <div className="flex min-h-screen">
      {/* Sidebar */}
      <aside className="w-64 border-r bg-gray-50 p-6">
        <h1 className="text-xl font-bold mb-8">n8n-mcp</h1>

        <nav className="space-y-2">
          <Link
            href="/dashboard"
            className="block px-4 py-2 rounded hover:bg-gray-100"
          >
            Dashboard
          </Link>
          <Link
            href="/dashboard/api-keys"
            className="block px-4 py-2 rounded hover:bg-gray-100"
          >
            API Keys
          </Link>
          <Link
            href="/dashboard/n8n-config"
            className="block px-4 py-2 rounded hover:bg-gray-100"
          >
            n8n Configuration
          </Link>
          <Link
            href="/dashboard/usage"
            className="block px-4 py-2 rounded hover:bg-gray-100"
          >
            Usage
          </Link>
        </nav>

        <div className="mt-auto pt-8">
          <p className="text-sm text-gray-600 mb-2">{user.email}</p>
          <form action={signOut}>
            <button className="text-sm text-red-600 hover:underline">
              Sign out
            </button>
          </form>
        </div>
      </aside>

      {/* Main content */}
      <main className="flex-1 p-8">{children}</main>
    </div>
  );
}

7.2 Dashboard Overview Page

File: src/app/(dashboard)/dashboard/page.tsx

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 (
    <div className="space-y-8">
      <div>
        <h1 className="text-3xl font-bold">Dashboard</h1>
        <p className="text-gray-600 mt-2">
          Welcome to your n8n-mcp control panel
        </p>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <Card className="p-6">
          <h3 className="text-sm font-medium text-gray-600">API Keys</h3>
          <p className="text-3xl font-bold mt-2">{apiKeyCount || 0}</p>
        </Card>

        <Card className="p-6">
          <h3 className="text-sm font-medium text-gray-600">Requests Today</h3>
          <p className="text-3xl font-bold mt-2">{usageCount || 0}</p>
        </Card>

        <Card className="p-6">
          <h3 className="text-sm font-medium text-gray-600">n8n Status</h3>
          <p className="text-3xl font-bold mt-2">
            {n8nInstance?.is_active ? '✅' : '❌'}
          </p>
        </Card>
      </div>

      {!n8nInstance && (
        <Card className="p-6 bg-yellow-50 border-yellow-200">
          <h3 className="font-semibold">⚠️ Action Required</h3>
          <p className="mt-2 text-sm">
            You need to configure your n8n instance before using the service.
          </p>
          <a
            href="/dashboard/n8n-config"
            className="mt-4 inline-block text-blue-600 hover:underline"
          >
            Configure n8n 
          </a>
        </Card>
      )}
    </div>
  );
}

7.3 API Key Management Page

File: src/app/(dashboard)/api-keys/page.tsx

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 (
    <div className="space-y-8">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold">API Keys</h1>
          <p className="text-gray-600 mt-2">
            Manage your n8n-mcp API keys for MCP clients
          </p>
        </div>
        <CreateApiKeyButton />
      </div>

      <ApiKeyList apiKeys={apiKeys || []} />
    </div>
  );
}

File: src/components/create-api-key-button.tsx

'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<string | null>(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 (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>Create API Key</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Create API Key</DialogTitle>
        </DialogHeader>

        {!generatedKey ? (
          <div className="space-y-4">
            <div>
              <Label htmlFor="name">Key Name</Label>
              <Input
                id="name"
                placeholder="Claude Desktop"
                value={name}
                onChange={(e) => setName(e.target.value)}
              />
              <p className="text-xs text-gray-500 mt-1">
                A friendly name to identify this key
              </p>
            </div>

            <Button
              onClick={handleCreate}
              disabled={!name || loading}
              className="w-full"
            >
              {loading ? 'Generating...' : 'Generate Key'}
            </Button>
          </div>
        ) : (
          <div className="space-y-4">
            <div className="p-4 bg-yellow-50 border border-yellow-200 rounded">
              <p className="text-sm font-semibold mb-2">
                ⚠️ Save this key securely!
              </p>
              <p className="text-xs text-gray-600">
                You won't be able to see it again.
              </p>
            </div>

            <div>
              <Label>Your API Key</Label>
              <div className="mt-2 p-3 bg-gray-100 rounded font-mono text-sm break-all">
                {generatedKey}
              </div>
            </div>

            <Button
              onClick={() => {
                navigator.clipboard.writeText(generatedKey);
                alert('Copied to clipboard!');
              }}
              variant="outline"
              className="w-full"
            >
              Copy to Clipboard
            </Button>

            <Button onClick={handleClose} className="w-full">
              Done
            </Button>
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}

File: src/app/(dashboard)/api-keys/actions.ts

'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

'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 (
      <Card className="p-8 text-center">
        <p className="text-gray-600">No API keys yet. Create your first one!</p>
      </Card>
    );
  }

  return (
    <div className="space-y-4">
      {apiKeys.map((key) => (
        <Card key={key.id} className="p-6">
          <div className="flex items-center justify-between">
            <div className="flex-1">
              <h3 className="font-semibold">{key.name}</h3>
              <p className="text-sm text-gray-600 font-mono mt-1">
                {key.key_prefix}
              </p>
              <p className="text-xs text-gray-500 mt-2">
                Created: {new Date(key.created_at).toLocaleDateString()}
                {key.last_used_at && (
                  <> · Last used: {new Date(key.last_used_at).toLocaleString()}</>
                )}
              </p>
            </div>

            <div className="flex items-center gap-2">
              {key.is_active ? (
                <span className="text-sm text-green-600"> Active</span>
              ) : (
                <span className="text-sm text-gray-400"> Revoked</span>
              )}
              {key.is_active && (
                <Button
                  variant="outline"
                  size="sm"
                  onClick={() => handleRevoke(key.id)}
                >
                  Revoke
                </Button>
              )}
            </div>
          </div>
        </Card>
      ))}
    </div>
  );
}

7.4 n8n Configuration Page

File: src/app/(dashboard)/n8n-config/page.tsx

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 (
    <div className="space-y-8">
      <div>
        <h1 className="text-3xl font-bold">n8n Configuration</h1>
        <p className="text-gray-600 mt-2">
          Connect your n8n instance to n8n-mcp
        </p>
      </div>

      <N8nConfigForm currentInstance={instance} />
    </div>
  );
}

File: src/components/n8n-config-form.tsx

'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 (
    <Card className="p-6 max-w-2xl">
      <div className="space-y-6">
        <div>
          <Label htmlFor="instanceUrl">n8n Instance URL</Label>
          <Input
            id="instanceUrl"
            type="url"
            placeholder="https://your-n8n-instance.com"
            value={instanceUrl}
            onChange={(e) => setInstanceUrl(e.target.value)}
          />
          <p className="text-xs text-gray-500 mt-1">
            The URL of your n8n instance
          </p>
        </div>

        <div>
          <Label htmlFor="apiKey">n8n API Key</Label>
          <Input
            id="apiKey"
            type="password"
            placeholder="Enter your n8n API key"
            value={apiKey}
            onChange={(e) => setApiKey(e.target.value)}
          />
          <p className="text-xs text-gray-500 mt-1">
            Find this in your n8n Settings  API
          </p>
        </div>

        {testResult && (
          <div
            className={`p-4 rounded ${
              testResult === 'success'
                ? 'bg-green-50 text-green-800'
                : 'bg-red-50 text-red-800'
            }`}
          >
            {testResult === 'success' ? '✅ Connection successful!' : `❌ ${error}`}
          </div>
        )}

        <div className="flex gap-4">
          <Button
            onClick={handleTest}
            disabled={!instanceUrl || !apiKey || testing}
            variant="outline"
          >
            {testing ? 'Testing...' : 'Test Connection'}
          </Button>

          <Button
            onClick={handleSave}
            disabled={!instanceUrl || !apiKey || saving || testResult !== 'success'}
          >
            {saving ? 'Saving...' : 'Save Configuration'}
          </Button>
        </div>

        {currentInstance && (
          <div className="pt-4 border-t">
            <p className="text-sm text-gray-600">
              Current instance:{' '}
              <span className="font-mono">{currentInstance.instance_url}</span>
              <span className="ml-2">
                {currentInstance.is_active ? '✅ Active' : '❌ Inactive'}
              </span>
            </p>
          </div>
        )}
      </div>
    </Card>
  );
}

File: src/app/(dashboard)/n8n-config/actions.ts

'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<boolean> {
  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

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 (
    <div className="space-y-8">
      <div>
        <h1 className="text-3xl font-bold">Usage Statistics</h1>
        <p className="text-gray-600 mt-2">
          Track your n8n-mcp API usage
        </p>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
        <Card className="p-6">
          <h3 className="text-sm font-medium text-gray-600">Total Requests</h3>
          <p className="text-3xl font-bold mt-2">{totalRequests || 0}</p>
        </Card>

        <Card className="p-6">
          <h3 className="text-sm font-medium text-gray-600">Today's Requests</h3>
          <p className="text-3xl font-bold mt-2">{todayRequests || 0}</p>
        </Card>
      </div>

      <Card className="p-6">
        <h3 className="font-semibold mb-4">Recent Activity</h3>
        <div className="space-y-2">
          {recentLogs && recentLogs.length > 0 ? (
            recentLogs.map((log, i) => (
              <div
                key={i}
                className="flex items-center justify-between py-2 border-b last:border-0"
              >
                <span className="text-sm font-mono">{log.tool_name}</span>
                <div className="flex items-center gap-4">
                  <span
                    className={`text-sm ${
                      log.status === 'success'
                        ? 'text-green-600'
                        : 'text-red-600'
                    }`}
                  >
                    {log.status}
                  </span>
                  <span className="text-xs text-gray-500">
                    {new Date(log.created_at).toLocaleTimeString()}
                  </span>
                </div>
              </div>
            ))
          ) : (
            <p className="text-sm text-gray-600">No activity yet</p>
          )}
        </div>
      </Card>
    </div>
  );
}

9.2 Deploy Frontend to Vercel

# 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

# 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:

# 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:

// ~/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:

// ~/.cursor/mcp.json
{
  "servers": {
    "n8n-mcp": {
      "url": "https://api.n8n-mcp.com/mcp",
      "headers": {
        "Authorization": "Bearer nmcp_your_key_here"
      }
    }
  }
}

Windsurf:

// 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:

brew install siege  # Mac
sudo apt install siege  # Linux

Create test script: scripts/load-test.sh

#!/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

# 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:

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

# 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

# Run final checks
./scripts/pre-launch-check.sh

File: scripts/pre-launch-check.sh

#!/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 2>/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)

# 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:

# Send to remaining waitlist
# Continue monitoring

12.3 Monitoring During Launch

Real-time monitoring:

# 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:

# 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:

# 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:

// 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:

# 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:

# Check memory usage
docker stats

# Check session count
# Add logging to SessionManager

Solutions:

// 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:

# 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:

docker compose ps
docker compose logs -f n8n-mcp
docker compose logs -f caddy

Test API endpoint:

# 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:

-- 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:

# 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 <commit-hash>

# 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:

-- 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:

# Rollback Vercel deployment
vercel rollback

# Or deploy previous version
git checkout <previous-commit>
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:

# Check all services
docker compose ps
docker compose logs --tail=100

# Check server resources
htop
df -h

# Check Supabase status
# Visit Supabase dashboard
  1. Recovery:
# 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
  1. 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! 🚀