mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
feat: add flexible instance configuration support with security improvements
- Add InstanceContext interface for runtime configuration - Implement dual-mode API client (singleton + instance-specific) - Add secure SHA-256 hashing for cache keys - Implement LRU cache with TTL (100 instances, 30min expiry) - Add comprehensive input validation for URLs and API keys - Sanitize all logging to prevent API key exposure - Fix session context cleanup and memory management - Add comprehensive security and integration tests - Maintain full backward compatibility for single-player usage Security improvements based on code review: - Cache keys are now cryptographically hashed - API credentials never appear in logs - Memory-bounded cache prevents resource exhaustion - Input validation rejects invalid/placeholder values - Proper cleanup of orphaned session contexts 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,60 +1,116 @@
|
||||
import { N8nApiClient } from '../services/n8n-api-client';
|
||||
import { getN8nApiConfig } from '../config/n8n-api';
|
||||
import {
|
||||
Workflow,
|
||||
WorkflowNode,
|
||||
import { getN8nApiConfig, getN8nApiConfigFromContext } from '../config/n8n-api';
|
||||
import {
|
||||
Workflow,
|
||||
WorkflowNode,
|
||||
WorkflowConnection,
|
||||
ExecutionStatus,
|
||||
WebhookRequest,
|
||||
McpToolResponse
|
||||
McpToolResponse
|
||||
} from '../types/n8n-api';
|
||||
import {
|
||||
validateWorkflowStructure,
|
||||
import {
|
||||
validateWorkflowStructure,
|
||||
hasWebhookTrigger,
|
||||
getWebhookUrl
|
||||
getWebhookUrl
|
||||
} from '../services/n8n-validation';
|
||||
import {
|
||||
N8nApiError,
|
||||
import {
|
||||
N8nApiError,
|
||||
N8nNotFoundError,
|
||||
getUserFriendlyErrorMessage
|
||||
getUserFriendlyErrorMessage
|
||||
} from '../utils/n8n-errors';
|
||||
import { logger } from '../utils/logger';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowValidator } from '../services/workflow-validator';
|
||||
import { EnhancedConfigValidator } from '../services/enhanced-config-validator';
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { InstanceContext, validateInstanceContext } from '../types/instance-context';
|
||||
import { createHash } from 'crypto';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
// Singleton n8n API client instance
|
||||
let apiClient: N8nApiClient | null = null;
|
||||
let lastConfigUrl: string | null = null;
|
||||
// Singleton n8n API client instance (backward compatibility)
|
||||
let defaultApiClient: N8nApiClient | null = null;
|
||||
let lastDefaultConfigUrl: string | null = null;
|
||||
|
||||
// Get or create API client (with lazy config loading)
|
||||
export function getN8nApiClient(): N8nApiClient | null {
|
||||
// Instance-specific API clients cache with LRU eviction and TTL
|
||||
const instanceClients = new LRUCache<string, N8nApiClient>({
|
||||
max: 100, // Maximum 100 cached instances
|
||||
ttl: 30 * 60 * 1000, // 30 minutes TTL
|
||||
updateAgeOnGet: true, // Reset TTL on access
|
||||
dispose: (client, key) => {
|
||||
// Clean up when evicting from cache
|
||||
logger.debug('Evicting API client from cache', {
|
||||
cacheKey: key.substring(0, 8) + '...' // Only log partial key for security
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get or create API client with flexible instance support
|
||||
* @param context - Optional instance context for instance-specific configuration
|
||||
* @returns API client configured for the instance or environment
|
||||
*/
|
||||
export function getN8nApiClient(context?: InstanceContext): N8nApiClient | null {
|
||||
// If context provided with n8n config, use instance-specific client
|
||||
if (context?.n8nApiUrl && context?.n8nApiKey) {
|
||||
// Validate context before using
|
||||
const validation = validateInstanceContext(context);
|
||||
if (!validation.valid) {
|
||||
logger.warn('Invalid instance context provided', {
|
||||
instanceId: context.instanceId,
|
||||
errors: validation.errors
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// Create secure hash of credentials for cache key
|
||||
const cacheKey = createHash('sha256')
|
||||
.update(`${context.n8nApiUrl}:${context.n8nApiKey}:${context.instanceId || ''}`)
|
||||
.digest('hex');
|
||||
|
||||
if (!instanceClients.has(cacheKey)) {
|
||||
const config = getN8nApiConfigFromContext(context);
|
||||
if (config) {
|
||||
// Sanitized logging - never log API keys
|
||||
logger.info('Creating instance-specific n8n API client', {
|
||||
url: config.baseUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1'), // Only log domain
|
||||
instanceId: context.instanceId,
|
||||
cacheKey: cacheKey.substring(0, 8) + '...' // Only log partial hash
|
||||
});
|
||||
instanceClients.set(cacheKey, new N8nApiClient(config));
|
||||
}
|
||||
}
|
||||
|
||||
return instanceClients.get(cacheKey) || null;
|
||||
}
|
||||
|
||||
// Fall back to default singleton from environment
|
||||
const config = getN8nApiConfig();
|
||||
|
||||
|
||||
if (!config) {
|
||||
if (apiClient) {
|
||||
logger.info('n8n API configuration removed, clearing client');
|
||||
apiClient = null;
|
||||
lastConfigUrl = null;
|
||||
if (defaultApiClient) {
|
||||
logger.info('n8n API configuration removed, clearing default client');
|
||||
defaultApiClient = null;
|
||||
lastDefaultConfigUrl = null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Check if config has changed
|
||||
if (!apiClient || lastConfigUrl !== config.baseUrl) {
|
||||
logger.info('n8n API client initialized', { url: config.baseUrl });
|
||||
apiClient = new N8nApiClient(config);
|
||||
lastConfigUrl = config.baseUrl;
|
||||
if (!defaultApiClient || lastDefaultConfigUrl !== config.baseUrl) {
|
||||
logger.info('n8n API client initialized from environment', { url: config.baseUrl });
|
||||
defaultApiClient = new N8nApiClient(config);
|
||||
lastDefaultConfigUrl = config.baseUrl;
|
||||
}
|
||||
|
||||
return apiClient;
|
||||
|
||||
return defaultApiClient;
|
||||
}
|
||||
|
||||
// Helper to ensure API is configured
|
||||
function ensureApiConfigured(): N8nApiClient {
|
||||
const client = getN8nApiClient();
|
||||
function ensureApiConfigured(context?: InstanceContext): N8nApiClient {
|
||||
const client = getN8nApiClient(context);
|
||||
if (!client) {
|
||||
if (context?.instanceId) {
|
||||
throw new Error(`n8n API not configured for instance ${context.instanceId}. Please provide n8nApiUrl and n8nApiKey in the instance context.`);
|
||||
}
|
||||
throw new Error('n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.');
|
||||
}
|
||||
return client;
|
||||
@@ -123,9 +179,9 @@ const listExecutionsSchema = z.object({
|
||||
|
||||
// Workflow Management Handlers
|
||||
|
||||
export async function handleCreateWorkflow(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleCreateWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = createWorkflowSchema.parse(args);
|
||||
|
||||
// Validate workflow structure
|
||||
@@ -171,9 +227,9 @@ export async function handleCreateWorkflow(args: unknown): Promise<McpToolRespon
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetWorkflow(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleGetWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = z.object({ id: z.string() }).parse(args);
|
||||
|
||||
const workflow = await client.getWorkflow(id);
|
||||
@@ -206,9 +262,9 @@ export async function handleGetWorkflow(args: unknown): Promise<McpToolResponse>
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetWorkflowDetails(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleGetWorkflowDetails(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = z.object({ id: z.string() }).parse(args);
|
||||
|
||||
const workflow = await client.getWorkflow(id);
|
||||
@@ -260,9 +316,9 @@ export async function handleGetWorkflowDetails(args: unknown): Promise<McpToolRe
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetWorkflowStructure(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleGetWorkflowStructure(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = z.object({ id: z.string() }).parse(args);
|
||||
|
||||
const workflow = await client.getWorkflow(id);
|
||||
@@ -313,9 +369,9 @@ export async function handleGetWorkflowStructure(args: unknown): Promise<McpTool
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetWorkflowMinimal(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleGetWorkflowMinimal(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = z.object({ id: z.string() }).parse(args);
|
||||
|
||||
const workflow = await client.getWorkflow(id);
|
||||
@@ -356,9 +412,9 @@ export async function handleGetWorkflowMinimal(args: unknown): Promise<McpToolRe
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleUpdateWorkflow(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleUpdateWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = updateWorkflowSchema.parse(args);
|
||||
const { id, ...updateData } = input;
|
||||
|
||||
@@ -418,9 +474,9 @@ export async function handleUpdateWorkflow(args: unknown): Promise<McpToolRespon
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteWorkflow(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleDeleteWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = z.object({ id: z.string() }).parse(args);
|
||||
|
||||
await client.deleteWorkflow(id);
|
||||
@@ -453,9 +509,9 @@ export async function handleDeleteWorkflow(args: unknown): Promise<McpToolRespon
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleListWorkflows(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleListWorkflows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = listWorkflowsSchema.parse(args || {});
|
||||
|
||||
const response = await client.listWorkflows({
|
||||
@@ -516,11 +572,12 @@ export async function handleListWorkflows(args: unknown): Promise<McpToolRespons
|
||||
}
|
||||
|
||||
export async function handleValidateWorkflow(
|
||||
args: unknown,
|
||||
repository: NodeRepository
|
||||
args: unknown,
|
||||
repository: NodeRepository,
|
||||
context?: InstanceContext
|
||||
): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = validateWorkflowSchema.parse(args);
|
||||
|
||||
// First, fetch the workflow from n8n
|
||||
@@ -605,9 +662,9 @@ export async function handleValidateWorkflow(
|
||||
|
||||
// Execution Management Handlers
|
||||
|
||||
export async function handleTriggerWebhookWorkflow(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleTriggerWebhookWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = triggerWebhookSchema.parse(args);
|
||||
|
||||
const webhookRequest: WebhookRequest = {
|
||||
@@ -650,9 +707,9 @@ export async function handleTriggerWebhookWorkflow(args: unknown): Promise<McpTo
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetExecution(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleGetExecution(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id, includeData } = z.object({
|
||||
id: z.string(),
|
||||
includeData: z.boolean().optional()
|
||||
@@ -688,9 +745,9 @@ export async function handleGetExecution(args: unknown): Promise<McpToolResponse
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleListExecutions(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleListExecutions(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = listExecutionsSchema.parse(args || {});
|
||||
|
||||
const response = await client.listExecutions({
|
||||
@@ -738,9 +795,9 @@ export async function handleListExecutions(args: unknown): Promise<McpToolRespon
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteExecution(args: unknown): Promise<McpToolResponse> {
|
||||
export async function handleDeleteExecution(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = z.object({ id: z.string() }).parse(args);
|
||||
|
||||
await client.deleteExecution(id);
|
||||
@@ -775,9 +832,9 @@ export async function handleDeleteExecution(args: unknown): Promise<McpToolRespo
|
||||
|
||||
// System Tools Handlers
|
||||
|
||||
export async function handleHealthCheck(): Promise<McpToolResponse> {
|
||||
export async function handleHealthCheck(context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured();
|
||||
const client = ensureApiConfigured(context);
|
||||
const health = await client.healthCheck();
|
||||
|
||||
// Get MCP version from package.json
|
||||
@@ -818,7 +875,7 @@ export async function handleHealthCheck(): Promise<McpToolResponse> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleListAvailableTools(): Promise<McpToolResponse> {
|
||||
export async function handleListAvailableTools(context?: InstanceContext): Promise<McpToolResponse> {
|
||||
const tools = [
|
||||
{
|
||||
category: 'Workflow Management',
|
||||
@@ -876,7 +933,7 @@ export async function handleListAvailableTools(): Promise<McpToolResponse> {
|
||||
}
|
||||
|
||||
// Handler: n8n_diagnostic
|
||||
export async function handleDiagnostic(request: any): Promise<McpToolResponse> {
|
||||
export async function handleDiagnostic(request: any, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
const verbose = request.params?.arguments?.verbose || false;
|
||||
|
||||
// Check environment variables
|
||||
@@ -890,7 +947,7 @@ export async function handleDiagnostic(request: any): Promise<McpToolResponse> {
|
||||
// Check API configuration
|
||||
const apiConfig = getN8nApiConfig();
|
||||
const apiConfigured = apiConfig !== null;
|
||||
const apiClient = getN8nApiClient();
|
||||
const apiClient = getN8nApiClient(context);
|
||||
|
||||
// Test API connectivity if configured
|
||||
let apiStatus = {
|
||||
|
||||
Reference in New Issue
Block a user