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>
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
- Prerequisites
- Phase 0: Environment Setup (Day 0)
- Phase 1: Backend Implementation (Days 1-4)
- Phase 2: Frontend Implementation (Days 5-9)
- Phase 3: Testing & Launch (Days 10-12)
- Troubleshooting
- 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-mcprepo - Supabase account (free tier)
- Hetzner Cloud account
- Domain access to
n8n-mcp.comDNS
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:
- Go to https://supabase.com/dashboard
- Click "New Project"
- Fill in:
- Name:
n8n-mcp-production - Database Password: Generate strong password (save securely!)
- Region: Europe (Frankfurt) - closest to Hetzner
- Plan: Free tier
- Name:
- 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:
- Go to https://console.hetzner.cloud
- Create new project:
n8n-mcp-production - Add server:
- Type: CPX31 (4 vCPU, 8GB RAM)
- Location: Falkenstein, Germany
- Image: Ubuntu 22.04 LTS
- Add SSH key
- 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
- Go to SQL Editor in Supabase dashboard
- Paste entire schema
- 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:
- Go to Authentication > Settings
- Enable Email provider (already enabled)
- Configure Email Templates:
- Confirmation: Customize subject/body
- Magic Link: Disable (not using for MVP)
- Site URL:
https://www.n8n-mcp.com - 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:
- User 1 creates API key
- User 2 creates API key
- Verify User 1 cannot see User 2's keys
- Make MCP requests with both keys
- Verify usage logs are isolated
- 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:
-
Immediate Actions:
- Post status update (Twitter, Discord)
- Email all active users
- Disable signup temporarily
-
Investigation:
# Check all services
docker compose ps
docker compose logs --tail=100
# Check server resources
htop
df -h
# Check Supabase status
# Visit Supabase dashboard
- 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
- 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! 🚀