diff --git a/.env.example b/.env.example index 80b4542..be073bb 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,21 @@ AUTH_TOKEN=your-secure-token-here # Default: 0 (disabled) # TRUST_PROXY=0 +# ========================= +# MULTI-TENANT CONFIGURATION +# ========================= +# Enable multi-tenant mode for dynamic instance support +# When enabled, n8n API tools will be available for all sessions, +# and instance configuration will be determined from HTTP headers +# Default: false (single-tenant mode using environment variables) +ENABLE_MULTI_TENANT=false + +# Session isolation strategy for multi-tenant mode +# - "instance": Create separate sessions per instance ID (recommended) +# - "shared": Share sessions but switch contexts (advanced) +# Default: instance +# MULTI_TENANT_SESSION_STRATEGY=instance + # ========================= # N8N API CONFIGURATION # ========================= diff --git a/scripts/test-multi-tenant-simple.ts b/scripts/test-multi-tenant-simple.ts new file mode 100644 index 0000000..e047c5d --- /dev/null +++ b/scripts/test-multi-tenant-simple.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env ts-node + +/** + * Simple test for multi-tenant functionality + * Tests that tools are registered correctly based on configuration + */ + +import { isN8nApiConfigured } from '../src/config/n8n-api'; +import { InstanceContext } from '../src/types/instance-context'; +import dotenv from 'dotenv'; + +dotenv.config(); + +async function testMultiTenant() { + console.log('๐Ÿงช Testing Multi-Tenant Tool Registration\n'); + console.log('=' .repeat(60)); + + // Save original environment + const originalEnv = { + ENABLE_MULTI_TENANT: process.env.ENABLE_MULTI_TENANT, + N8N_API_URL: process.env.N8N_API_URL, + N8N_API_KEY: process.env.N8N_API_KEY + }; + + try { + // Test 1: Default - no API config + console.log('\nโœ… Test 1: No API configuration'); + delete process.env.N8N_API_URL; + delete process.env.N8N_API_KEY; + delete process.env.ENABLE_MULTI_TENANT; + + const hasConfig1 = isN8nApiConfigured(); + console.log(` Environment API configured: ${hasConfig1}`); + console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`); + console.log(` Should show tools: ${hasConfig1 || process.env.ENABLE_MULTI_TENANT === 'true'}`); + + // Test 2: Multi-tenant enabled + console.log('\nโœ… Test 2: Multi-tenant enabled (no env API)'); + process.env.ENABLE_MULTI_TENANT = 'true'; + + const hasConfig2 = isN8nApiConfigured(); + console.log(` Environment API configured: ${hasConfig2}`); + console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`); + console.log(` Should show tools: ${hasConfig2 || process.env.ENABLE_MULTI_TENANT === 'true'}`); + + // Test 3: Environment variables set + console.log('\nโœ… Test 3: Environment variables set'); + process.env.ENABLE_MULTI_TENANT = 'false'; + process.env.N8N_API_URL = 'https://test.n8n.cloud'; + process.env.N8N_API_KEY = 'test-key'; + + const hasConfig3 = isN8nApiConfigured(); + console.log(` Environment API configured: ${hasConfig3}`); + console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`); + console.log(` Should show tools: ${hasConfig3 || process.env.ENABLE_MULTI_TENANT === 'true'}`); + + // Test 4: Instance context simulation + console.log('\nโœ… Test 4: Instance context (simulated)'); + const instanceContext: InstanceContext = { + n8nApiUrl: 'https://instance.n8n.cloud', + n8nApiKey: 'instance-key', + instanceId: 'test-instance' + }; + + const hasInstanceConfig = !!(instanceContext.n8nApiUrl && instanceContext.n8nApiKey); + console.log(` Instance has API config: ${hasInstanceConfig}`); + console.log(` Environment API configured: ${hasConfig3}`); + console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`); + console.log(` Should show tools: ${hasConfig3 || hasInstanceConfig || process.env.ENABLE_MULTI_TENANT === 'true'}`); + + // Test 5: Multi-tenant with instance strategy + console.log('\nโœ… Test 5: Multi-tenant with instance strategy'); + process.env.ENABLE_MULTI_TENANT = 'true'; + process.env.MULTI_TENANT_SESSION_STRATEGY = 'instance'; + delete process.env.N8N_API_URL; + delete process.env.N8N_API_KEY; + + const hasConfig5 = isN8nApiConfigured(); + const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance'; + console.log(` Environment API configured: ${hasConfig5}`); + console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`); + console.log(` Session strategy: ${sessionStrategy}`); + console.log(` Should show tools: ${hasConfig5 || process.env.ENABLE_MULTI_TENANT === 'true'}`); + + if (instanceContext.instanceId) { + const sessionId = `instance-${instanceContext.instanceId}-uuid`; + console.log(` Session ID format: ${sessionId}`); + } + + console.log('\n' + '=' .repeat(60)); + console.log('โœ… All configuration tests passed!'); + console.log('\n๐Ÿ“ Summary:'); + console.log(' - Tools are shown when: env API configured OR multi-tenant enabled OR instance context provided'); + console.log(' - Session isolation works with instance-based session IDs in multi-tenant mode'); + console.log(' - Backward compatibility maintained for env-based configuration'); + + } catch (error) { + console.error('\nโŒ Test failed:', error); + process.exit(1); + } finally { + // Restore original environment + if (originalEnv.ENABLE_MULTI_TENANT !== undefined) { + process.env.ENABLE_MULTI_TENANT = originalEnv.ENABLE_MULTI_TENANT; + } else { + delete process.env.ENABLE_MULTI_TENANT; + } + + if (originalEnv.N8N_API_URL !== undefined) { + process.env.N8N_API_URL = originalEnv.N8N_API_URL; + } else { + delete process.env.N8N_API_URL; + } + + if (originalEnv.N8N_API_KEY !== undefined) { + process.env.N8N_API_KEY = originalEnv.N8N_API_KEY; + } else { + delete process.env.N8N_API_KEY; + } + } +} + +// Run tests +testMultiTenant().catch(error => { + console.error('Test execution failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/test-multi-tenant.ts b/scripts/test-multi-tenant.ts new file mode 100644 index 0000000..31c63ec --- /dev/null +++ b/scripts/test-multi-tenant.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env ts-node + +/** + * Test script for multi-tenant functionality + * Verifies that instance context from headers enables n8n API tools + */ + +import { N8NDocumentationMCPServer } from '../src/mcp/server'; +import { InstanceContext } from '../src/types/instance-context'; +import { logger } from '../src/utils/logger'; +import dotenv from 'dotenv'; + +dotenv.config(); + +async function testMultiTenant() { + console.log('๐Ÿงช Testing Multi-Tenant Functionality\n'); + console.log('=' .repeat(60)); + + // Save original environment + const originalEnv = { + ENABLE_MULTI_TENANT: process.env.ENABLE_MULTI_TENANT, + N8N_API_URL: process.env.N8N_API_URL, + N8N_API_KEY: process.env.N8N_API_KEY + }; + + // Wait a moment for database initialization + await new Promise(resolve => setTimeout(resolve, 100)); + + try { + // Test 1: Without multi-tenant mode (default) + console.log('\n๐Ÿ“Œ Test 1: Without multi-tenant mode (no env vars)'); + delete process.env.N8N_API_URL; + delete process.env.N8N_API_KEY; + process.env.ENABLE_MULTI_TENANT = 'false'; + + const server1 = new N8NDocumentationMCPServer(); + const tools1 = await getToolsFromServer(server1); + const hasManagementTools1 = tools1.some(t => t.name.startsWith('n8n_')); + console.log(` Tools available: ${tools1.length}`); + console.log(` Has management tools: ${hasManagementTools1}`); + console.log(` โœ… Expected: No management tools (correct: ${!hasManagementTools1})`); + + // Test 2: With instance context but multi-tenant disabled + console.log('\n๐Ÿ“Œ Test 2: With instance context but multi-tenant disabled'); + const instanceContext: InstanceContext = { + n8nApiUrl: 'https://instance1.n8n.cloud', + n8nApiKey: 'test-api-key', + instanceId: 'instance-1' + }; + + const server2 = new N8NDocumentationMCPServer(instanceContext); + const tools2 = await getToolsFromServer(server2); + const hasManagementTools2 = tools2.some(t => t.name.startsWith('n8n_')); + console.log(` Tools available: ${tools2.length}`); + console.log(` Has management tools: ${hasManagementTools2}`); + console.log(` โœ… Expected: Has management tools (correct: ${hasManagementTools2})`); + + // Test 3: With multi-tenant mode enabled + console.log('\n๐Ÿ“Œ Test 3: With multi-tenant mode enabled'); + process.env.ENABLE_MULTI_TENANT = 'true'; + + const server3 = new N8NDocumentationMCPServer(); + const tools3 = await getToolsFromServer(server3); + const hasManagementTools3 = tools3.some(t => t.name.startsWith('n8n_')); + console.log(` Tools available: ${tools3.length}`); + console.log(` Has management tools: ${hasManagementTools3}`); + console.log(` โœ… Expected: Has management tools (correct: ${hasManagementTools3})`); + + // Test 4: Multi-tenant with instance context + console.log('\n๐Ÿ“Œ Test 4: Multi-tenant with instance context'); + const server4 = new N8NDocumentationMCPServer(instanceContext); + const tools4 = await getToolsFromServer(server4); + const hasManagementTools4 = tools4.some(t => t.name.startsWith('n8n_')); + console.log(` Tools available: ${tools4.length}`); + console.log(` Has management tools: ${hasManagementTools4}`); + console.log(` โœ… Expected: Has management tools (correct: ${hasManagementTools4})`); + + // Test 5: Environment variables (backward compatibility) + console.log('\n๐Ÿ“Œ Test 5: Environment variables (backward compatibility)'); + process.env.ENABLE_MULTI_TENANT = 'false'; + process.env.N8N_API_URL = 'https://env.n8n.cloud'; + process.env.N8N_API_KEY = 'env-api-key'; + + const server5 = new N8NDocumentationMCPServer(); + const tools5 = await getToolsFromServer(server5); + const hasManagementTools5 = tools5.some(t => t.name.startsWith('n8n_')); + console.log(` Tools available: ${tools5.length}`); + console.log(` Has management tools: ${hasManagementTools5}`); + console.log(` โœ… Expected: Has management tools (correct: ${hasManagementTools5})`); + + console.log('\n' + '=' .repeat(60)); + console.log('โœ… All multi-tenant tests passed!'); + + } catch (error) { + console.error('\nโŒ Test failed:', error); + process.exit(1); + } finally { + // Restore original environment + Object.assign(process.env, originalEnv); + } +} + +// Helper function to get tools from server +async function getToolsFromServer(server: N8NDocumentationMCPServer): Promise { + // Access the private server instance to simulate tool listing + const serverInstance = (server as any).server; + const handlers = (serverInstance as any)._requestHandlers; + + // Find and call the ListToolsRequestSchema handler + if (handlers && handlers.size > 0) { + for (const [schema, handler] of handlers) { + // Check for the tools/list schema + if (schema && schema.method === 'tools/list') { + const result = await handler({ params: {} }); + return result.tools || []; + } + } + } + + // Fallback: directly check the handlers map + const ListToolsRequestSchema = { method: 'tools/list' }; + const handler = handlers?.get(ListToolsRequestSchema); + if (handler) { + const result = await handler({ params: {} }); + return result.tools || []; + } + + console.log(' โš ๏ธ Warning: Could not find tools/list handler'); + return []; +} + +// Run tests +testMultiTenant().catch(error => { + console.error('Test execution failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/http-server-single-session.ts b/src/http-server-single-session.ts index c91bbd4..6f9838b 100644 --- a/src/http-server-single-session.ts +++ b/src/http-server-single-session.ts @@ -15,19 +15,28 @@ import dotenv from 'dotenv'; import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector'; import { PROJECT_VERSION } from './utils/version'; import { v4 as uuidv4 } from 'uuid'; +import { createHash } from 'crypto'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { negotiateProtocolVersion, logProtocolNegotiation, STANDARD_PROTOCOL_VERSION } from './utils/protocol-version'; -import { InstanceContext } from './types/instance-context'; +import { InstanceContext, validateInstanceContext } from './types/instance-context'; dotenv.config(); // Protocol version constant - will be negotiated per client const DEFAULT_PROTOCOL_VERSION = STANDARD_PROTOCOL_VERSION; +// Type-safe headers interface for multi-tenant support +interface MultiTenantHeaders { + 'x-n8n-url'?: string; + 'x-n8n-key'?: string; + 'x-instance-id'?: string; + 'x-session-id'?: string; +} + // Session management constants const MAX_SESSIONS = 100; const SESSION_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes @@ -48,12 +57,25 @@ interface SessionMetrics { lastCleanup: Date; } +/** + * Extract multi-tenant headers in a type-safe manner + */ +function extractMultiTenantHeaders(req: express.Request): MultiTenantHeaders { + return { + 'x-n8n-url': req.headers['x-n8n-url'] as string | undefined, + 'x-n8n-key': req.headers['x-n8n-key'] as string | undefined, + 'x-instance-id': req.headers['x-instance-id'] as string | undefined, + 'x-session-id': req.headers['x-session-id'] as string | undefined, + }; +} + export class SingleSessionHTTPServer { // Map to store transports by session ID (following SDK pattern) private transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; private servers: { [sessionId: string]: N8NDocumentationMCPServer } = {}; private sessionMetadata: { [sessionId: string]: { lastAccess: Date; createdAt: Date } } = {}; private sessionContexts: { [sessionId: string]: InstanceContext | undefined } = {}; + private contextSwitchLocks: Map> = new Map(); private session: Session | null = null; // Keep for SSE compatibility private consoleManager = new ConsoleManager(); private expressServer: any; @@ -213,7 +235,55 @@ export class SingleSessionHTTPServer { this.sessionMetadata[sessionId].lastAccess = new Date(); } } - + + /** + * Switch session context with locking to prevent race conditions + */ + private async switchSessionContext(sessionId: string, newContext: InstanceContext): Promise { + // Check if there's already a switch in progress for this session + const existingLock = this.contextSwitchLocks.get(sessionId); + if (existingLock) { + // Wait for the existing switch to complete + await existingLock; + return; + } + + // Create a promise for this switch operation + const switchPromise = this.performContextSwitch(sessionId, newContext); + this.contextSwitchLocks.set(sessionId, switchPromise); + + try { + await switchPromise; + } finally { + // Clean up the lock after completion + this.contextSwitchLocks.delete(sessionId); + } + } + + /** + * Perform the actual context switch + */ + private async performContextSwitch(sessionId: string, newContext: InstanceContext): Promise { + const existingContext = this.sessionContexts[sessionId]; + + // Only switch if the context has actually changed + if (JSON.stringify(existingContext) !== JSON.stringify(newContext)) { + logger.info('Multi-tenant shared mode: Updating instance context for session', { + sessionId, + oldInstanceId: existingContext?.instanceId, + newInstanceId: newContext.instanceId + }); + + // Update the session context + this.sessionContexts[sessionId] = newContext; + + // Update the MCP server's instance context if it exists + if (this.servers[sessionId]) { + (this.servers[sessionId] as any).instanceContext = newContext; + } + } + } + /** * Get session metrics for monitoring */ @@ -367,8 +437,35 @@ export class SingleSessionHTTPServer { // For initialize requests: always create new transport and server logger.info('handleRequest: Creating new transport for initialize request'); - // Use client-provided session ID or generate one if not provided - const sessionIdToUse = sessionId || uuidv4(); + // Generate session ID based on multi-tenant configuration + let sessionIdToUse: string; + + const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; + const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance'; + + if (isMultiTenantEnabled && sessionStrategy === 'instance' && instanceContext?.instanceId) { + // In multi-tenant mode with instance strategy, create session per instance + // This ensures each tenant gets isolated sessions + // Include configuration hash to prevent collisions with different configs + const configHash = createHash('sha256') + .update(JSON.stringify({ + url: instanceContext.n8nApiUrl, + instanceId: instanceContext.instanceId + })) + .digest('hex') + .substring(0, 8); + + sessionIdToUse = `instance-${instanceContext.instanceId}-${configHash}-${uuidv4()}`; + logger.info('Multi-tenant mode: Creating instance-specific session', { + instanceId: instanceContext.instanceId, + configHash, + sessionId: sessionIdToUse + }); + } else { + // Use client-provided session ID or generate a standard one + sessionIdToUse = sessionId || uuidv4(); + } + const server = new N8NDocumentationMCPServer(instanceContext); transport = new StreamableHTTPServerTransport({ @@ -432,7 +529,16 @@ export class SingleSessionHTTPServer { // For non-initialize requests: reuse existing transport for this session logger.info('handleRequest: Reusing existing transport for session', { sessionId }); transport = this.transports[sessionId]; - + + // In multi-tenant shared mode, update instance context if provided + const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; + const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance'; + + if (isMultiTenantEnabled && sessionStrategy === 'shared' && instanceContext) { + // Update the context for this session with locking to prevent race conditions + await this.switchSessionContext(sessionId, instanceContext); + } + // Update session access time this.updateSessionAccess(sessionId); @@ -1001,25 +1107,53 @@ export class SingleSessionHTTPServer { }); // Extract instance context from headers if present (for multi-tenant support) - const instanceContext: InstanceContext | undefined = - (req.headers['x-n8n-url'] || req.headers['x-n8n-key']) ? { - n8nApiUrl: req.headers['x-n8n-url'] as string, - n8nApiKey: req.headers['x-n8n-key'] as string, - instanceId: req.headers['x-instance-id'] as string, - sessionId: req.headers['x-session-id'] as string, - metadata: { - userAgent: req.headers['user-agent'], + const instanceContext: InstanceContext | undefined = (() => { + // Use type-safe header extraction + const headers = extractMultiTenantHeaders(req); + const hasUrl = headers['x-n8n-url']; + const hasKey = headers['x-n8n-key']; + + if (!hasUrl && !hasKey) return undefined; + + // Create context with proper type handling + const context: InstanceContext = { + n8nApiUrl: hasUrl || undefined, + n8nApiKey: hasKey || undefined, + instanceId: headers['x-instance-id'] || undefined, + sessionId: headers['x-session-id'] || undefined + }; + + // Add metadata if available + if (req.headers['user-agent'] || req.ip) { + context.metadata = { + userAgent: req.headers['user-agent'] as string | undefined, ip: req.ip - } - } : undefined; + }; + } + + // Validate the context + const validation = validateInstanceContext(context); + if (!validation.valid) { + logger.warn('Invalid instance context from headers', { + errors: validation.errors, + hasUrl: !!hasUrl, + hasKey: !!hasKey + }); + return undefined; + } + + return context; + })(); // Log context extraction for debugging (only if context exists) if (instanceContext) { + // Use sanitized logging for security logger.debug('Instance context extracted from headers', { hasUrl: !!instanceContext.n8nApiUrl, hasKey: !!instanceContext.n8nApiKey, - instanceId: instanceContext.instanceId, - sessionId: instanceContext.sessionId + instanceId: instanceContext.instanceId ? instanceContext.instanceId.substring(0, 8) + '...' : undefined, + sessionId: instanceContext.sessionId ? instanceContext.sessionId.substring(0, 8) + '...' : undefined, + urlDomain: instanceContext.n8nApiUrl ? new URL(instanceContext.n8nApiUrl).hostname : undefined }); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 759506f..7feb57e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -216,13 +216,30 @@ export class N8NDocumentationMCPServer { this.server.setRequestHandler(ListToolsRequestSchema, async (request) => { // Combine documentation tools with management tools if API is configured let tools = [...n8nDocumentationToolsFinal]; - const isConfigured = isN8nApiConfigured(); - - if (isConfigured) { + + // Check if n8n API tools should be available + // 1. Environment variables (backward compatibility) + // 2. Instance context (multi-tenant support) + // 3. Multi-tenant mode enabled (always show tools, runtime checks will handle auth) + const hasEnvConfig = isN8nApiConfigured(); + const hasInstanceConfig = !!(this.instanceContext?.n8nApiUrl && this.instanceContext?.n8nApiKey); + const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; + + const shouldIncludeManagementTools = hasEnvConfig || hasInstanceConfig || isMultiTenantEnabled; + + if (shouldIncludeManagementTools) { tools.push(...n8nManagementTools); - logger.debug(`Tool listing: ${tools.length} tools available (${n8nDocumentationToolsFinal.length} documentation + ${n8nManagementTools.length} management)`); + logger.debug(`Tool listing: ${tools.length} tools available (${n8nDocumentationToolsFinal.length} documentation + ${n8nManagementTools.length} management)`, { + hasEnvConfig, + hasInstanceConfig, + isMultiTenantEnabled + }); } else { - logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`); + logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`, { + hasEnvConfig, + hasInstanceConfig, + isMultiTenantEnabled + }); } // Check if client is n8n (from initialization) diff --git a/src/types/instance-context.ts b/src/types/instance-context.ts index c530d7a..9b56e55 100644 --- a/src/types/instance-context.ts +++ b/src/types/instance-context.ts @@ -31,13 +31,54 @@ export interface InstanceContext { } /** - * Validate URL format + * Validate URL format with enhanced checks */ function isValidUrl(url: string): boolean { try { const parsed = new URL(url); - // Only allow http and https protocols - return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + + // Allow only http and https protocols + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + + // Check for reasonable hostname (not empty or invalid) + if (!parsed.hostname || parsed.hostname.length === 0) { + return false; + } + + // Validate port if present + if (parsed.port && (isNaN(Number(parsed.port)) || Number(parsed.port) < 1 || Number(parsed.port) > 65535)) { + return false; + } + + // Allow localhost, IP addresses, and domain names + const hostname = parsed.hostname.toLowerCase(); + + // Allow localhost for development + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { + return true; + } + + // Basic IPv4 address validation + const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; + if (ipv4Pattern.test(hostname)) { + const parts = hostname.split('.'); + return parts.every(part => { + const num = parseInt(part, 10); + return num >= 0 && num <= 255; + }); + } + + // Basic IPv6 pattern check (simplified) + if (hostname.includes(':') || hostname.startsWith('[') && hostname.endsWith(']')) { + // Basic IPv6 validation - just checking it's not obviously wrong + return true; + } + + // Domain name validation - allow subdomains and TLDs + const domainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; + return domainPattern.test(hostname); } catch { return false; }