From 74f05e937fa7d94babe3507510caa17ce17a698c Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:09:09 +0200 Subject: [PATCH] feat: integrate n8n management tools from n8n-manager-for-ai-agents (v2.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added 14 n8n management tools for workflow CRUD and execution management - Integrated n8n API client with full error handling and validation - Added conditional tool registration (only when N8N_API_URL configured) - Complete workflow lifecycle: discover → build → validate → deploy → execute - Updated documentation and added integration tests - Maintains backward compatibility - existing functionality unchanged šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 21 +- CLAUDE.md | 41 +- README.md | 31 +- package.json | 1 + src/config/n8n-api.ts | 56 ++ src/mcp/handlers-n8n-manager.ts | 732 ++++++++++++++++++++ src/mcp/server-update.ts | 50 +- src/mcp/tools-n8n-manager.ts | 322 +++++++++ src/scripts/test-n8n-manager-integration.ts | 147 ++++ src/services/n8n-api-client.ts | 390 +++++++++++ src/services/n8n-validation.ts | 207 ++++++ src/types/n8n-api.ts | 281 ++++++++ src/utils/n8n-errors.ts | 136 ++++ 13 files changed, 2409 insertions(+), 6 deletions(-) create mode 100644 src/config/n8n-api.ts create mode 100644 src/mcp/handlers-n8n-manager.ts create mode 100644 src/mcp/tools-n8n-manager.ts create mode 100644 src/scripts/test-n8n-manager-integration.ts create mode 100644 src/services/n8n-api-client.ts create mode 100644 src/services/n8n-validation.ts create mode 100644 src/types/n8n-api.ts create mode 100644 src/utils/n8n-errors.ts diff --git a/.env.example b/.env.example index e9a43cf..36124f8 100644 --- a/.env.example +++ b/.env.example @@ -51,4 +51,23 @@ AUTH_TOKEN=your-secure-token-here # CORS origin for HTTP mode (optional) # Default: * (allow all origins) # For production, set to your specific domain -# CORS_ORIGIN=https://your-client-domain.com \ No newline at end of file +# CORS_ORIGIN=https://your-client-domain.com + +# ========================= +# N8N API CONFIGURATION +# ========================= +# Optional: Enable n8n management tools by providing API credentials +# These tools allow creating, updating, and executing workflows + +# n8n instance API URL (without /api/v1 suffix) +# Example: https://your-n8n-instance.com +# N8N_API_URL= + +# n8n API Key (get from Settings > API in your n8n instance) +# N8N_API_KEY= + +# n8n API request timeout in milliseconds (default: 30000) +# N8N_API_TIMEOUT=30000 + +# Maximum number of API request retries (default: 3) +# N8N_API_MAX_RETRIES=3 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b35e0c3..fb957ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co n8n-mcp is a comprehensive documentation and knowledge server that provides AI assistants with complete access to n8n node information through the Model Context Protocol (MCP). It serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively. -## āœ… Latest Updates (v2.5.1) +## āœ… Latest Updates (v2.6.0) + +### Update (v2.6.0) - n8n Management Tools Integration: +- āœ… **NEW: 14 n8n management tools** - Create, update, execute workflows via API +- āœ… **NEW: n8n_create_workflow** - Create workflows programmatically +- āœ… **NEW: n8n_update_workflow** - Update existing workflows +- āœ… **NEW: n8n_trigger_webhook_workflow** - Execute workflows via webhooks +- āœ… **NEW: n8n_list_executions** - Monitor workflow executions +- āœ… **NEW: n8n_health_check** - Check n8n instance connectivity +- āœ… Integrated n8n-manager-for-ai-agents functionality +- āœ… Optional feature - only enabled when N8N_API_URL and N8N_API_KEY configured +- āœ… Complete workflow lifecycle: discover → build → validate → deploy → execute +- āœ… Smart error handling for API limitations (activation, direct execution) +- āœ… Conditional tool registration based on configuration + +## āœ… Previous Updates (v2.5.1) ### Update (v2.5.1) - AI Tool Support Enhancements: - āœ… **NEW: get_node_as_tool_info tool** - Get specific information about using ANY node as an AI tool @@ -195,6 +210,7 @@ npm run test:ai-workflow-validation # Test AI workflow validation npm run test:mcp-tools # Test MCP tool enhancements npm run test:single-session # Test single session HTTP npm run test:template-validation # Test template validation +npm run test:n8n-manager # Test n8n management tools integration # Workflow Validation Commands: npm run test:workflow-validation # Test workflow validation features @@ -321,6 +337,29 @@ The project implements MCP (Model Context Protocol) to expose n8n node documenta - `search_templates` - **NEW** Search templates by keywords - `get_templates_for_task` - **NEW** Get curated templates for common tasks +### n8n Management Tools (NEW v2.6.0 - Requires API Configuration) +These tools are only available when N8N_API_URL and N8N_API_KEY are configured: + +#### Workflow Management +- `n8n_create_workflow` - Create new workflows with nodes and connections +- `n8n_get_workflow` - Get complete workflow by ID +- `n8n_get_workflow_details` - Get workflow with execution statistics +- `n8n_get_workflow_structure` - Get simplified workflow structure +- `n8n_get_workflow_minimal` - Get minimal workflow info +- `n8n_update_workflow` - Update existing workflows +- `n8n_delete_workflow` - Delete workflows permanently +- `n8n_list_workflows` - List workflows with filtering + +#### Execution Management +- `n8n_trigger_webhook_workflow` - Trigger workflows via webhook URL +- `n8n_get_execution` - Get execution details by ID +- `n8n_list_executions` - List executions with status filtering +- `n8n_delete_execution` - Delete execution records + +#### System Tools +- `n8n_health_check` - Check n8n API connectivity and features +- `n8n_list_available_tools` - List all available management tools + ### Database Structure Uses SQLite with enhanced schema: - **nodes** table: Core node information with FTS5 indexing diff --git a/README.md b/README.md index 1159b01..b5c432b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,9 @@ Add to Claude Desktop config: "-e", "MCP_MODE=stdio", "-e", "LOG_LEVEL=error", "-e", "DISABLE_CONSOLE_OUTPUT=true", + // Optional: Enable n8n management tools + // "-e", "N8N_API_URL=https://your-n8n-instance.com", + // "-e", "N8N_API_KEY=your-api-key", "ghcr.io/czlonkowski/n8n-mcp:latest" ] } @@ -191,6 +194,29 @@ Once connected, Claude can use these powerful tools: - **`get_node_documentation`** - Get parsed documentation from n8n-docs - **`get_database_statistics`** - View database metrics and coverage +### n8n Management Tools (NEW! Requires API Configuration) +These tools allow you to manage n8n workflows directly. Configure with `N8N_API_URL` and `N8N_API_KEY`. + +#### Workflow Management +- **`n8n_create_workflow`** - Create new workflows with nodes and connections +- **`n8n_get_workflow`** - Get complete workflow by ID +- **`n8n_get_workflow_details`** - Get workflow with execution statistics +- **`n8n_get_workflow_structure`** - Get simplified workflow structure +- **`n8n_get_workflow_minimal`** - Get minimal workflow info (ID, name, active status) +- **`n8n_update_workflow`** - Update existing workflows +- **`n8n_delete_workflow`** - Delete workflows permanently +- **`n8n_list_workflows`** - List workflows with filtering and pagination + +#### Execution Management +- **`n8n_trigger_webhook_workflow`** - Trigger workflows via webhook URL +- **`n8n_get_execution`** - Get execution details by ID +- **`n8n_list_executions`** - List executions with status filtering +- **`n8n_delete_execution`** - Delete execution records + +#### System Tools +- **`n8n_health_check`** - Check n8n API connectivity and features +- **`n8n_list_available_tools`** - List all available management tools + ### Example Usage ```typescript @@ -237,7 +263,10 @@ If you prefer running locally: "NODE_ENV": "production", "LOG_LEVEL": "error", "MCP_MODE": "stdio", - "DISABLE_CONSOLE_OUTPUT": "true" + "DISABLE_CONSOLE_OUTPUT": "true", + // Optional: Enable n8n management tools + // "N8N_API_URL": "https://your-n8n-instance.com", + // "N8N_API_KEY": "your-api-key" } } } diff --git a/package.json b/package.json index d3c76c0..f07385c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:enhanced-validation": "node dist/scripts/test-enhanced-validation.js", "test:ai-workflow-validation": "node dist/scripts/test-ai-workflow-validation.js", "test:mcp-tools": "node dist/scripts/test-mcp-tools.js", + "test:n8n-manager": "node dist/scripts/test-n8n-manager-integration.js", "db:rebuild": "node dist/scripts/rebuild-database.js", "db:init": "node -e \"new (require('./dist/services/sqlite-storage-service').SQLiteStorageService)(); console.log('Database initialized')\"", "docs:rebuild": "ts-node src/scripts/rebuild-database.ts" diff --git a/src/config/n8n-api.ts b/src/config/n8n-api.ts new file mode 100644 index 0000000..9ae4c33 --- /dev/null +++ b/src/config/n8n-api.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; +import dotenv from 'dotenv'; +import { logger } from '../utils/logger'; + +// Load environment variables +dotenv.config(); + +// n8n API configuration schema +const n8nApiConfigSchema = z.object({ + N8N_API_URL: z.string().url().optional(), + N8N_API_KEY: z.string().min(1).optional(), + N8N_API_TIMEOUT: z.coerce.number().positive().default(30000), + N8N_API_MAX_RETRIES: z.coerce.number().positive().default(3), +}); + +// Parse and validate n8n API configuration +export function loadN8nApiConfig() { + const result = n8nApiConfigSchema.safeParse(process.env); + + if (!result.success) { + logger.warn('n8n API configuration validation failed:', result.error.format()); + return null; + } + + const config = result.data; + + // Check if both URL and API key are provided + if (!config.N8N_API_URL || !config.N8N_API_KEY) { + logger.info('n8n API not configured. Management tools will be disabled.'); + return null; + } + + logger.info('n8n API configured successfully', { + url: config.N8N_API_URL, + timeout: config.N8N_API_TIMEOUT, + maxRetries: config.N8N_API_MAX_RETRIES, + }); + + return { + baseUrl: config.N8N_API_URL, + apiKey: config.N8N_API_KEY, + timeout: config.N8N_API_TIMEOUT, + maxRetries: config.N8N_API_MAX_RETRIES, + }; +} + +// Export the configuration (null if not configured) +export const n8nApiConfig = loadN8nApiConfig(); + +// Helper to check if n8n API is configured +export function isN8nApiConfigured(): boolean { + return n8nApiConfig !== null; +} + +// Type export +export type N8nApiConfig = NonNullable>; \ No newline at end of file diff --git a/src/mcp/handlers-n8n-manager.ts b/src/mcp/handlers-n8n-manager.ts new file mode 100644 index 0000000..c3492f2 --- /dev/null +++ b/src/mcp/handlers-n8n-manager.ts @@ -0,0 +1,732 @@ +import { N8nApiClient } from '../services/n8n-api-client'; +import { n8nApiConfig } from '../config/n8n-api'; +import { + Workflow, + WorkflowNode, + WorkflowConnection, + ExecutionStatus, + WebhookRequest, + McpToolResponse +} from '../types/n8n-api'; +import { + validateWorkflowStructure, + hasWebhookTrigger, + getWebhookUrl +} from '../services/n8n-validation'; +import { + N8nApiError, + N8nNotFoundError, + getUserFriendlyErrorMessage +} from '../utils/n8n-errors'; +import { logger } from '../utils/logger'; +import { z } from 'zod'; + +// Singleton n8n API client instance +let apiClient: N8nApiClient | null = null; + +// Get or create API client +export function getN8nApiClient(): N8nApiClient | null { + if (!n8nApiConfig) { + return null; + } + + if (!apiClient) { + apiClient = new N8nApiClient(n8nApiConfig); + } + + return apiClient; +} + +// Helper to ensure API is configured +function ensureApiConfigured(): N8nApiClient { + const client = getN8nApiClient(); + if (!client) { + throw new Error('n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.'); + } + return client; +} + +// Zod schemas for input validation +const createWorkflowSchema = z.object({ + name: z.string(), + nodes: z.array(z.any()), + connections: z.record(z.any()), + settings: z.object({ + executionOrder: z.enum(['v0', 'v1']).optional(), + timezone: z.string().optional(), + saveDataErrorExecution: z.enum(['all', 'none']).optional(), + saveDataSuccessExecution: z.enum(['all', 'none']).optional(), + saveManualExecutions: z.boolean().optional(), + saveExecutionProgress: z.boolean().optional(), + executionTimeout: z.number().optional(), + errorWorkflow: z.string().optional(), + }).optional(), +}); + +const updateWorkflowSchema = z.object({ + id: z.string(), + name: z.string().optional(), + nodes: z.array(z.any()).optional(), + connections: z.record(z.any()).optional(), + settings: z.any().optional(), +}); + +const listWorkflowsSchema = z.object({ + limit: z.number().min(1).max(100).optional(), + cursor: z.string().optional(), + active: z.boolean().optional(), + tags: z.array(z.string()).optional(), + projectId: z.string().optional(), + excludePinnedData: z.boolean().optional(), +}); + +const triggerWebhookSchema = z.object({ + webhookUrl: z.string().url(), + httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(), + data: z.record(z.unknown()).optional(), + headers: z.record(z.string()).optional(), + waitForResponse: z.boolean().optional(), +}); + +const listExecutionsSchema = z.object({ + limit: z.number().min(1).max(100).optional(), + cursor: z.string().optional(), + workflowId: z.string().optional(), + projectId: z.string().optional(), + status: z.enum(['success', 'error', 'waiting']).optional(), + includeData: z.boolean().optional(), +}); + +// Workflow Management Handlers + +export async function handleCreateWorkflow(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const input = createWorkflowSchema.parse(args); + + // Validate workflow structure + const errors = validateWorkflowStructure(input); + if (errors.length > 0) { + return { + success: false, + error: 'Workflow validation failed', + details: { errors } + }; + } + + // Create workflow + const workflow = await client.createWorkflow(input); + + return { + success: true, + data: workflow, + message: `Workflow "${workflow.name}" created successfully with ID: ${workflow.id}` + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code, + details: error.details as Record | undefined + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleGetWorkflow(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const { id } = z.object({ id: z.string() }).parse(args); + + const workflow = await client.getWorkflow(id); + + return { + success: true, + data: workflow + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleGetWorkflowDetails(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const { id } = z.object({ id: z.string() }).parse(args); + + const workflow = await client.getWorkflow(id); + + // Get recent executions for this workflow + const executions = await client.listExecutions({ + workflowId: id, + limit: 10 + }); + + // Calculate execution statistics + const stats = { + totalExecutions: executions.data.length, + successCount: executions.data.filter(e => e.status === ExecutionStatus.SUCCESS).length, + errorCount: executions.data.filter(e => e.status === ExecutionStatus.ERROR).length, + lastExecutionTime: executions.data[0]?.startedAt || null + }; + + return { + success: true, + data: { + workflow, + executionStats: stats, + hasWebhookTrigger: hasWebhookTrigger(workflow), + webhookPath: getWebhookUrl(workflow) + } + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleGetWorkflowStructure(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const { id } = z.object({ id: z.string() }).parse(args); + + const workflow = await client.getWorkflow(id); + + // Simplify nodes to just essential structure + const simplifiedNodes = workflow.nodes.map(node => ({ + id: node.id, + name: node.name, + type: node.type, + position: node.position, + disabled: node.disabled || false + })); + + return { + success: true, + data: { + id: workflow.id, + name: workflow.name, + active: workflow.active, + nodes: simplifiedNodes, + connections: workflow.connections, + nodeCount: workflow.nodes.length, + connectionCount: Object.keys(workflow.connections).length + } + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleGetWorkflowMinimal(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const { id } = z.object({ id: z.string() }).parse(args); + + const workflow = await client.getWorkflow(id); + + return { + success: true, + data: { + id: workflow.id, + name: workflow.name, + active: workflow.active, + tags: workflow.tags || [], + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt + } + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleUpdateWorkflow(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const input = updateWorkflowSchema.parse(args); + const { id, ...updateData } = input; + + // If nodes/connections are being updated, validate the structure + if (updateData.nodes || updateData.connections) { + // Fetch current workflow if only partial update + let fullWorkflow = updateData as Partial; + + if (!updateData.nodes || !updateData.connections) { + const current = await client.getWorkflow(id); + fullWorkflow = { + ...current, + ...updateData + }; + } + + const errors = validateWorkflowStructure(fullWorkflow); + if (errors.length > 0) { + return { + success: false, + error: 'Workflow validation failed', + details: { errors } + }; + } + } + + // Update workflow + const workflow = await client.updateWorkflow(id, updateData); + + return { + success: true, + data: workflow, + message: `Workflow "${workflow.name}" updated successfully` + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code, + details: error.details as Record | undefined + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleDeleteWorkflow(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const { id } = z.object({ id: z.string() }).parse(args); + + await client.deleteWorkflow(id); + + return { + success: true, + message: `Workflow ${id} deleted successfully` + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleListWorkflows(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const input = listWorkflowsSchema.parse(args || {}); + + const response = await client.listWorkflows({ + limit: input.limit || 100, + cursor: input.cursor, + active: input.active, + tags: input.tags, + projectId: input.projectId, + excludePinnedData: input.excludePinnedData ?? true + }); + + return { + success: true, + data: { + workflows: response.data, + nextCursor: response.nextCursor, + total: response.data.length + } + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +// Execution Management Handlers + +export async function handleTriggerWebhookWorkflow(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const input = triggerWebhookSchema.parse(args); + + const webhookRequest: WebhookRequest = { + webhookUrl: input.webhookUrl, + httpMethod: input.httpMethod || 'POST', + data: input.data, + headers: input.headers, + waitForResponse: input.waitForResponse ?? true + }; + + const response = await client.triggerWebhook(webhookRequest); + + return { + success: true, + data: response, + message: 'Webhook triggered successfully' + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code, + details: error.details as Record | undefined + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleGetExecution(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const { id, includeData } = z.object({ + id: z.string(), + includeData: z.boolean().optional() + }).parse(args); + + const execution = await client.getExecution(id, includeData || false); + + return { + success: true, + data: execution + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleListExecutions(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const input = listExecutionsSchema.parse(args || {}); + + const response = await client.listExecutions({ + limit: input.limit || 100, + cursor: input.cursor, + workflowId: input.workflowId, + projectId: input.projectId, + status: input.status as ExecutionStatus | undefined, + includeData: input.includeData || false + }); + + return { + success: true, + data: { + executions: response.data, + nextCursor: response.nextCursor, + total: response.data.length + } + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleDeleteExecution(args: unknown): Promise { + try { + const client = ensureApiConfigured(); + const { id } = z.object({ id: z.string() }).parse(args); + + await client.deleteExecution(id); + + return { + success: true, + message: `Execution ${id} deleted successfully` + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +// System Tools Handlers + +export async function handleHealthCheck(): Promise { + try { + const client = ensureApiConfigured(); + const health = await client.healthCheck(); + + return { + success: true, + data: { + status: health.status, + instanceId: health.instanceId, + n8nVersion: health.n8nVersion, + features: health.features, + apiUrl: n8nApiConfig?.baseUrl + } + }; + } catch (error) { + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code, + details: { + apiUrl: n8nApiConfig?.baseUrl, + hint: 'Check if n8n is running and API is enabled' + } + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +export async function handleListAvailableTools(): Promise { + const tools = [ + { + category: 'Workflow Management', + tools: [ + { name: 'n8n_create_workflow', description: 'Create new workflows' }, + { name: 'n8n_get_workflow', description: 'Get workflow by ID' }, + { name: 'n8n_get_workflow_details', description: 'Get detailed workflow info with stats' }, + { name: 'n8n_get_workflow_structure', description: 'Get simplified workflow structure' }, + { name: 'n8n_get_workflow_minimal', description: 'Get minimal workflow info' }, + { name: 'n8n_update_workflow', description: 'Update existing workflows' }, + { name: 'n8n_delete_workflow', description: 'Delete workflows' }, + { name: 'n8n_list_workflows', description: 'List workflows with filters' } + ] + }, + { + category: 'Execution Management', + tools: [ + { name: 'n8n_trigger_webhook_workflow', description: 'Trigger workflows via webhook' }, + { name: 'n8n_get_execution', description: 'Get execution details' }, + { name: 'n8n_list_executions', description: 'List executions with filters' }, + { name: 'n8n_delete_execution', description: 'Delete execution records' } + ] + }, + { + category: 'System', + tools: [ + { name: 'n8n_health_check', description: 'Check API connectivity' }, + { name: 'n8n_list_available_tools', description: 'List all available tools' } + ] + } + ]; + + const apiConfigured = n8nApiConfig !== null; + + return { + success: true, + data: { + tools, + apiConfigured, + configuration: apiConfigured ? { + apiUrl: n8nApiConfig!.baseUrl, + timeout: n8nApiConfig!.timeout, + maxRetries: n8nApiConfig!.maxRetries + } : null, + limitations: [ + 'Cannot activate/deactivate workflows via API', + 'Cannot execute workflows directly (must use webhooks)', + 'Cannot stop running executions', + 'Tags and credentials have limited API support' + ] + } + }; +} \ No newline at end of file diff --git a/src/mcp/server-update.ts b/src/mcp/server-update.ts index 703ba75..5c87f9e 100644 --- a/src/mcp/server-update.ts +++ b/src/mcp/server-update.ts @@ -8,6 +8,7 @@ import { import { existsSync } from 'fs'; import path from 'path'; import { n8nDocumentationToolsFinal } from './tools-update'; +import { n8nManagementTools } from './tools-n8n-manager'; import { logger } from '../utils/logger'; import { NodeRepository } from '../database/node-repository'; import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter'; @@ -20,6 +21,8 @@ import { PropertyDependencies } from '../services/property-dependencies'; import { SimpleCache } from '../utils/simple-cache'; import { TemplateService } from '../templates/template-service'; import { WorkflowValidator } from '../services/workflow-validator'; +import { isN8nApiConfigured } from '../config/n8n-api'; +import * as n8nHandlers from './handlers-n8n-manager'; interface NodeRow { node_type: string; @@ -130,9 +133,19 @@ export class N8NDocumentationMCPServer { }); // Handle tool listing - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: n8nDocumentationToolsFinal, - })); + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + // Combine documentation tools with management tools if API is configured + const tools = [...n8nDocumentationToolsFinal]; + + if (isN8nApiConfigured()) { + tools.push(...n8nManagementTools); + logger.info('n8n management tools enabled'); + } else { + logger.info('n8n management tools disabled (API not configured)'); + } + + return { tools }; + }); // Handle tool execution this.server.setRequestHandler(CallToolRequestSchema, async (request) => { @@ -211,6 +224,37 @@ export class N8NDocumentationMCPServer { return this.validateWorkflowConnections(args.workflow); case 'validate_workflow_expressions': return this.validateWorkflowExpressions(args.workflow); + + // n8n Management Tools (if API is configured) + case 'n8n_create_workflow': + return n8nHandlers.handleCreateWorkflow(args); + case 'n8n_get_workflow': + return n8nHandlers.handleGetWorkflow(args); + case 'n8n_get_workflow_details': + return n8nHandlers.handleGetWorkflowDetails(args); + case 'n8n_get_workflow_structure': + return n8nHandlers.handleGetWorkflowStructure(args); + case 'n8n_get_workflow_minimal': + return n8nHandlers.handleGetWorkflowMinimal(args); + case 'n8n_update_workflow': + return n8nHandlers.handleUpdateWorkflow(args); + case 'n8n_delete_workflow': + return n8nHandlers.handleDeleteWorkflow(args); + case 'n8n_list_workflows': + return n8nHandlers.handleListWorkflows(args); + case 'n8n_trigger_webhook_workflow': + return n8nHandlers.handleTriggerWebhookWorkflow(args); + case 'n8n_get_execution': + return n8nHandlers.handleGetExecution(args); + case 'n8n_list_executions': + return n8nHandlers.handleListExecutions(args); + case 'n8n_delete_execution': + return n8nHandlers.handleDeleteExecution(args); + case 'n8n_health_check': + return n8nHandlers.handleHealthCheck(); + case 'n8n_list_available_tools': + return n8nHandlers.handleListAvailableTools(); + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/mcp/tools-n8n-manager.ts b/src/mcp/tools-n8n-manager.ts new file mode 100644 index 0000000..b4d8d7f --- /dev/null +++ b/src/mcp/tools-n8n-manager.ts @@ -0,0 +1,322 @@ +import { ToolDefinition } from '../types'; + +/** + * n8n Management Tools + * + * These tools enable AI agents to manage n8n workflows through the n8n API. + * They require N8N_API_URL and N8N_API_KEY to be configured. + */ +export const n8nManagementTools: ToolDefinition[] = [ + // Workflow Management Tools + { + name: 'n8n_create_workflow', + description: `Create a new workflow in n8n. Requires workflow name, nodes array, and connections object. The workflow will be created in inactive state and must be manually activated in the UI. Returns the created workflow with its ID.`, + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Workflow name (required)' + }, + nodes: { + type: 'array', + description: 'Array of workflow nodes. Each node must have: id, name, type, typeVersion, position, and parameters', + items: { + type: 'object', + required: ['id', 'name', 'type', 'typeVersion', 'position', 'parameters'], + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + type: { type: 'string' }, + typeVersion: { type: 'number' }, + position: { + type: 'array', + items: { type: 'number' }, + minItems: 2, + maxItems: 2 + }, + parameters: { type: 'object' }, + credentials: { type: 'object' }, + disabled: { type: 'boolean' }, + notes: { type: 'string' }, + continueOnFail: { type: 'boolean' }, + retryOnFail: { type: 'boolean' }, + maxTries: { type: 'number' }, + waitBetweenTries: { type: 'number' } + } + } + }, + connections: { + type: 'object', + description: 'Workflow connections object. Keys are source node IDs, values define output connections' + }, + settings: { + type: 'object', + description: 'Optional workflow settings (execution order, timezone, error handling)', + properties: { + executionOrder: { type: 'string', enum: ['v0', 'v1'] }, + timezone: { type: 'string' }, + saveDataErrorExecution: { type: 'string', enum: ['all', 'none'] }, + saveDataSuccessExecution: { type: 'string', enum: ['all', 'none'] }, + saveManualExecutions: { type: 'boolean' }, + saveExecutionProgress: { type: 'boolean' }, + executionTimeout: { type: 'number' }, + errorWorkflow: { type: 'string' } + } + } + }, + required: ['name', 'nodes', 'connections'] + } + }, + { + name: 'n8n_get_workflow', + description: `Get a workflow by ID. Returns the complete workflow including nodes, connections, and settings.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Workflow ID' + } + }, + required: ['id'] + } + }, + { + name: 'n8n_get_workflow_details', + description: `Get detailed workflow information including metadata, version, and execution statistics. More comprehensive than get_workflow.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Workflow ID' + } + }, + required: ['id'] + } + }, + { + name: 'n8n_get_workflow_structure', + description: `Get simplified workflow structure showing only nodes and their connections. Useful for understanding workflow flow without parameter details.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Workflow ID' + } + }, + required: ['id'] + } + }, + { + name: 'n8n_get_workflow_minimal', + description: `Get minimal workflow information (ID, name, active status, tags). Fast and lightweight for listing purposes.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Workflow ID' + } + }, + required: ['id'] + } + }, + { + name: 'n8n_update_workflow', + description: `Update an existing workflow. Requires the full nodes array when modifying nodes/connections. Cannot activate workflows via API - use UI instead.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Workflow ID to update' + }, + name: { + type: 'string', + description: 'New workflow name' + }, + nodes: { + type: 'array', + description: 'Complete array of workflow nodes (required if modifying workflow structure)' + }, + connections: { + type: 'object', + description: 'Complete connections object (required if modifying workflow structure)' + }, + settings: { + type: 'object', + description: 'Workflow settings to update' + } + }, + required: ['id'] + } + }, + { + name: 'n8n_delete_workflow', + description: `Permanently delete a workflow. This action cannot be undone.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Workflow ID to delete' + } + }, + required: ['id'] + } + }, + { + name: 'n8n_list_workflows', + description: `List workflows with optional filters. Supports pagination via cursor.`, + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Number of workflows to return (1-100, default: 100)' + }, + cursor: { + type: 'string', + description: 'Pagination cursor from previous response' + }, + active: { + type: 'boolean', + description: 'Filter by active status' + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by tags (exact match)' + }, + projectId: { + type: 'string', + description: 'Filter by project ID (enterprise feature)' + }, + excludePinnedData: { + type: 'boolean', + description: 'Exclude pinned data from response (default: true)' + } + } + } + }, + + // Execution Management Tools + { + name: 'n8n_trigger_webhook_workflow', + description: `Trigger a workflow via webhook. Workflow must be ACTIVE and have a Webhook trigger node. HTTP method must match webhook configuration.`, + inputSchema: { + type: 'object', + properties: { + webhookUrl: { + type: 'string', + description: 'Full webhook URL from n8n workflow (e.g., https://n8n.example.com/webhook/abc-def-ghi)' + }, + httpMethod: { + type: 'string', + enum: ['GET', 'POST', 'PUT', 'DELETE'], + description: 'HTTP method (must match webhook configuration, often GET)' + }, + data: { + type: 'object', + description: 'Data to send with the webhook request' + }, + headers: { + type: 'object', + description: 'Additional HTTP headers' + }, + waitForResponse: { + type: 'boolean', + description: 'Wait for workflow completion (default: true)' + } + }, + required: ['webhookUrl'] + } + }, + { + name: 'n8n_get_execution', + description: `Get details of a specific execution by ID.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Execution ID' + }, + includeData: { + type: 'boolean', + description: 'Include full execution data (default: false)' + } + }, + required: ['id'] + } + }, + { + name: 'n8n_list_executions', + description: `List workflow executions with optional filters. Supports pagination.`, + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Number of executions to return (1-100, default: 100)' + }, + cursor: { + type: 'string', + description: 'Pagination cursor from previous response' + }, + workflowId: { + type: 'string', + description: 'Filter by workflow ID' + }, + projectId: { + type: 'string', + description: 'Filter by project ID (enterprise feature)' + }, + status: { + type: 'string', + enum: ['success', 'error', 'waiting'], + description: 'Filter by execution status' + }, + includeData: { + type: 'boolean', + description: 'Include execution data (default: false)' + } + } + } + }, + { + name: 'n8n_delete_execution', + description: `Delete an execution record. This only removes the execution history, not any data processed.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Execution ID to delete' + } + }, + required: ['id'] + } + }, + + // System Tools + { + name: 'n8n_health_check', + description: `Check n8n instance health and API connectivity. Returns status and available features.`, + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'n8n_list_available_tools', + description: `List all available n8n management tools and their capabilities. Useful for understanding what operations are possible.`, + inputSchema: { + type: 'object', + properties: {} + } + } +]; \ No newline at end of file diff --git a/src/scripts/test-n8n-manager-integration.ts b/src/scripts/test-n8n-manager-integration.ts new file mode 100644 index 0000000..42b2acb --- /dev/null +++ b/src/scripts/test-n8n-manager-integration.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import { config } from 'dotenv'; +import { logger } from '../utils/logger'; +import { isN8nApiConfigured, n8nApiConfig } from '../config/n8n-api'; +import { getN8nApiClient } from '../mcp/handlers-n8n-manager'; +import { N8nApiClient } from '../services/n8n-api-client'; +import { Workflow, ExecutionStatus } from '../types/n8n-api'; + +// Load environment variables +config(); + +async function testN8nManagerIntegration() { + logger.info('Testing n8n Manager Integration...'); + + // Check if API is configured + if (!isN8nApiConfigured()) { + logger.warn('n8n API not configured. Set N8N_API_URL and N8N_API_KEY to test.'); + logger.info('Example:'); + logger.info(' N8N_API_URL=https://your-n8n.com N8N_API_KEY=your-key npm run test:n8n-manager'); + return; + } + + logger.info('n8n API Configuration:', { + url: n8nApiConfig!.baseUrl, + timeout: n8nApiConfig!.timeout, + maxRetries: n8nApiConfig!.maxRetries + }); + + const client = getN8nApiClient(); + if (!client) { + logger.error('Failed to create n8n API client'); + return; + } + + try { + // Test 1: Health Check + logger.info('\n=== Test 1: Health Check ==='); + const health = await client.healthCheck(); + logger.info('Health check passed:', health); + + // Test 2: List Workflows + logger.info('\n=== Test 2: List Workflows ==='); + const workflows = await client.listWorkflows({ limit: 5 }); + logger.info(`Found ${workflows.data.length} workflows`); + workflows.data.forEach(wf => { + logger.info(`- ${wf.name} (ID: ${wf.id}, Active: ${wf.active})`); + }); + + // Test 3: Create a Test Workflow + logger.info('\n=== Test 3: Create Test Workflow ==='); + const testWorkflow: Partial = { + name: `Test Workflow - MCP Integration ${Date.now()}`, + nodes: [ + { + id: '1', + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [250, 300], + parameters: {} + }, + { + id: '2', + name: 'Set', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [450, 300], + parameters: { + values: { + string: [ + { + name: 'message', + value: 'Hello from MCP!' + } + ] + } + } + } + ], + connections: { + '1': { + main: [[{ node: '2', type: 'main', index: 0 }]] + } + }, + settings: { + executionOrder: 'v1', + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + saveManualExecutions: true, + saveExecutionProgress: true + } + }; + + const createdWorkflow = await client.createWorkflow(testWorkflow); + logger.info('Created workflow:', { + id: createdWorkflow.id, + name: createdWorkflow.name, + active: createdWorkflow.active + }); + + // Test 4: Get Workflow Details + logger.info('\n=== Test 4: Get Workflow Details ==='); + const workflowDetails = await client.getWorkflow(createdWorkflow.id!); + logger.info('Retrieved workflow:', { + id: workflowDetails.id, + name: workflowDetails.name, + nodeCount: workflowDetails.nodes.length + }); + + // Test 5: Update Workflow + logger.info('\n=== Test 5: Update Workflow ==='); + // n8n API requires full workflow structure for updates + const updatedWorkflow = await client.updateWorkflow(createdWorkflow.id!, { + name: `${createdWorkflow.name} - Updated`, + nodes: workflowDetails.nodes, + connections: workflowDetails.connections, + settings: workflowDetails.settings + }); + logger.info('Updated workflow name:', updatedWorkflow.name); + + // Test 6: List Executions + logger.info('\n=== Test 6: List Recent Executions ==='); + const executions = await client.listExecutions({ limit: 5 }); + logger.info(`Found ${executions.data.length} recent executions`); + executions.data.forEach(exec => { + logger.info(`- Workflow: ${exec.workflowName || exec.workflowId}, Status: ${exec.status}, Started: ${exec.startedAt}`); + }); + + // Test 7: Cleanup - Delete Test Workflow + logger.info('\n=== Test 7: Cleanup ==='); + await client.deleteWorkflow(createdWorkflow.id!); + logger.info('Deleted test workflow'); + + logger.info('\nāœ… All tests passed successfully!'); + + } catch (error) { + logger.error('Test failed:', error); + process.exit(1); + } +} + +// Run tests +testN8nManagerIntegration().catch(error => { + logger.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/services/n8n-api-client.ts b/src/services/n8n-api-client.ts new file mode 100644 index 0000000..18a817b --- /dev/null +++ b/src/services/n8n-api-client.ts @@ -0,0 +1,390 @@ +import axios, { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; +import { logger } from '../utils/logger'; +import { + Workflow, + WorkflowListParams, + WorkflowListResponse, + Execution, + ExecutionListParams, + ExecutionListResponse, + Credential, + CredentialListParams, + CredentialListResponse, + Tag, + TagListParams, + TagListResponse, + HealthCheckResponse, + Variable, + WebhookRequest, + WorkflowExport, + WorkflowImport, + SourceControlStatus, + SourceControlPullResult, + SourceControlPushResult, +} from '../types/n8n-api'; +import { handleN8nApiError, logN8nError } from '../utils/n8n-errors'; +import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation'; + +export interface N8nApiClientConfig { + baseUrl: string; + apiKey: string; + timeout?: number; + maxRetries?: number; +} + +export class N8nApiClient { + private client: AxiosInstance; + private maxRetries: number; + + constructor(config: N8nApiClientConfig) { + const { baseUrl, apiKey, timeout = 30000, maxRetries = 3 } = config; + + this.maxRetries = maxRetries; + + // Ensure baseUrl ends with /api/v1 + const apiUrl = baseUrl.endsWith('/api/v1') + ? baseUrl + : `${baseUrl.replace(/\/$/, '')}/api/v1`; + + this.client = axios.create({ + baseURL: apiUrl, + timeout, + headers: { + 'X-N8N-API-KEY': apiKey, + 'Content-Type': 'application/json', + }, + }); + + // Request interceptor for logging + this.client.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + logger.debug(`n8n API Request: ${config.method?.toUpperCase()} ${config.url}`, { + params: config.params, + data: config.data, + }); + return config; + }, + (error: unknown) => { + logger.error('n8n API Request Error:', error); + return Promise.reject(error); + } + ); + + // Response interceptor for logging + this.client.interceptors.response.use( + (response: any) => { + logger.debug(`n8n API Response: ${response.status} ${response.config.url}`); + return response; + }, + (error: unknown) => { + const n8nError = handleN8nApiError(error); + logN8nError(n8nError, 'n8n API Response'); + return Promise.reject(n8nError); + } + ); + } + + // Health check to verify API connectivity + async healthCheck(): Promise { + try { + // First try the health endpoint + const response = await this.client.get('/health'); + return response.data; + } catch (error) { + // If health endpoint doesn't exist, try listing workflows with limit 1 + // This is a fallback for older n8n versions + try { + await this.client.get('/workflows', { params: { limit: 1 } }); + return { + status: 'ok', + features: {} // We can't determine features without proper health endpoint + }; + } catch (fallbackError) { + throw handleN8nApiError(fallbackError); + } + } + } + + // Workflow Management + async createWorkflow(workflow: Partial): Promise { + try { + const cleanedWorkflow = cleanWorkflowForCreate(workflow); + const response = await this.client.post('/workflows', cleanedWorkflow); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async getWorkflow(id: string): Promise { + try { + const response = await this.client.get(`/workflows/${id}`); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async updateWorkflow(id: string, workflow: Partial): Promise { + try { + // First, try PUT method (newer n8n versions) + const cleanedWorkflow = cleanWorkflowForUpdate(workflow as Workflow); + try { + const response = await this.client.put(`/workflows/${id}`, cleanedWorkflow); + return response.data; + } catch (putError: any) { + // If PUT fails with 405 (Method Not Allowed), try PATCH + if (putError.response?.status === 405) { + logger.debug('PUT method not supported, falling back to PATCH'); + const response = await this.client.patch(`/workflows/${id}`, cleanedWorkflow); + return response.data; + } + throw putError; + } + } catch (error) { + throw handleN8nApiError(error); + } + } + + async deleteWorkflow(id: string): Promise { + try { + await this.client.delete(`/workflows/${id}`); + } catch (error) { + throw handleN8nApiError(error); + } + } + + async listWorkflows(params: WorkflowListParams = {}): Promise { + try { + const response = await this.client.get('/workflows', { params }); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + // Execution Management + async getExecution(id: string, includeData = false): Promise { + try { + const response = await this.client.get(`/executions/${id}`, { + params: { includeData }, + }); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async listExecutions(params: ExecutionListParams = {}): Promise { + try { + const response = await this.client.get('/executions', { params }); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async deleteExecution(id: string): Promise { + try { + await this.client.delete(`/executions/${id}`); + } catch (error) { + throw handleN8nApiError(error); + } + } + + // Webhook Execution + async triggerWebhook(request: WebhookRequest): Promise { + try { + const { webhookUrl, httpMethod, data, headers, waitForResponse = true } = request; + + // Extract path from webhook URL + const url = new URL(webhookUrl); + const webhookPath = url.pathname; + + // Make request directly to webhook endpoint + const config: AxiosRequestConfig = { + method: httpMethod, + url: webhookPath, + headers: { + ...headers, + // Don't override API key header for webhook endpoints + 'X-N8N-API-KEY': undefined, + }, + data: httpMethod !== 'GET' ? data : undefined, + params: httpMethod === 'GET' ? data : undefined, + // Webhooks might take longer + timeout: waitForResponse ? 120000 : 30000, + }; + + // Create a new axios instance for webhook requests to avoid API interceptors + const webhookClient = axios.create({ + baseURL: new URL('/', webhookUrl).toString(), + validateStatus: (status) => status < 500, // Don't throw on 4xx + }); + + const response = await webhookClient.request(config); + + return { + status: response.status, + statusText: response.statusText, + data: response.data, + headers: response.headers, + }; + } catch (error) { + throw handleN8nApiError(error); + } + } + + // Credential Management + async listCredentials(params: CredentialListParams = {}): Promise { + try { + const response = await this.client.get('/credentials', { params }); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async getCredential(id: string): Promise { + try { + const response = await this.client.get(`/credentials/${id}`); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async createCredential(credential: Partial): Promise { + try { + const response = await this.client.post('/credentials', credential); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async updateCredential(id: string, credential: Partial): Promise { + try { + const response = await this.client.patch(`/credentials/${id}`, credential); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async deleteCredential(id: string): Promise { + try { + await this.client.delete(`/credentials/${id}`); + } catch (error) { + throw handleN8nApiError(error); + } + } + + // Tag Management + async listTags(params: TagListParams = {}): Promise { + try { + const response = await this.client.get('/tags', { params }); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async createTag(tag: Partial): Promise { + try { + const response = await this.client.post('/tags', tag); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async updateTag(id: string, tag: Partial): Promise { + try { + const response = await this.client.patch(`/tags/${id}`, tag); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async deleteTag(id: string): Promise { + try { + await this.client.delete(`/tags/${id}`); + } catch (error) { + throw handleN8nApiError(error); + } + } + + // Source Control Management (Enterprise feature) + async getSourceControlStatus(): Promise { + try { + const response = await this.client.get('/source-control/status'); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async pullSourceControl(force = false): Promise { + try { + const response = await this.client.post('/source-control/pull', { force }); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async pushSourceControl( + message: string, + fileNames?: string[] + ): Promise { + try { + const response = await this.client.post('/source-control/push', { + message, + fileNames, + }); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + // Variable Management (via Source Control API) + async getVariables(): Promise { + try { + const response = await this.client.get('/variables'); + return response.data.data || []; + } catch (error) { + // Variables might not be available in all n8n versions + logger.warn('Variables API not available, returning empty array'); + return []; + } + } + + async createVariable(variable: Partial): Promise { + try { + const response = await this.client.post('/variables', variable); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async updateVariable(id: string, variable: Partial): Promise { + try { + const response = await this.client.patch(`/variables/${id}`, variable); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async deleteVariable(id: string): Promise { + try { + await this.client.delete(`/variables/${id}`); + } catch (error) { + throw handleN8nApiError(error); + } + } +} \ No newline at end of file diff --git a/src/services/n8n-validation.ts b/src/services/n8n-validation.ts new file mode 100644 index 0000000..cc78109 --- /dev/null +++ b/src/services/n8n-validation.ts @@ -0,0 +1,207 @@ +import { z } from 'zod'; +import { WorkflowNode, WorkflowConnection, Workflow } from '../types/n8n-api'; + +// Zod schemas for n8n API validation + +export const workflowNodeSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + typeVersion: z.number(), + position: z.tuple([z.number(), z.number()]), + parameters: z.record(z.unknown()), + credentials: z.record(z.string()).optional(), + disabled: z.boolean().optional(), + notes: z.string().optional(), + notesInFlow: z.boolean().optional(), + continueOnFail: z.boolean().optional(), + retryOnFail: z.boolean().optional(), + maxTries: z.number().optional(), + waitBetweenTries: z.number().optional(), + alwaysOutputData: z.boolean().optional(), + executeOnce: z.boolean().optional(), +}); + +export const workflowConnectionSchema = z.record( + z.object({ + main: z.array( + z.array( + z.object({ + node: z.string(), + type: z.string(), + index: z.number(), + }) + ) + ), + }) +); + +export const workflowSettingsSchema = z.object({ + executionOrder: z.enum(['v0', 'v1']).default('v1'), + timezone: z.string().optional(), + saveDataErrorExecution: z.enum(['all', 'none']).default('all'), + saveDataSuccessExecution: z.enum(['all', 'none']).default('all'), + saveManualExecutions: z.boolean().default(true), + saveExecutionProgress: z.boolean().default(true), + executionTimeout: z.number().optional(), + errorWorkflow: z.string().optional(), +}); + +// Default settings for workflow creation +export const defaultWorkflowSettings = { + executionOrder: 'v1' as const, + saveDataErrorExecution: 'all' as const, + saveDataSuccessExecution: 'all' as const, + saveManualExecutions: true, + saveExecutionProgress: true, +}; + +// Validation functions +export function validateWorkflowNode(node: unknown): WorkflowNode { + return workflowNodeSchema.parse(node); +} + +export function validateWorkflowConnections(connections: unknown): WorkflowConnection { + return workflowConnectionSchema.parse(connections); +} + +export function validateWorkflowSettings(settings: unknown): z.infer { + return workflowSettingsSchema.parse(settings); +} + +// Clean workflow data for API operations +export function cleanWorkflowForCreate(workflow: Partial): Partial { + const { + // Remove read-only fields + id, + createdAt, + updatedAt, + versionId, + meta, + // Remove fields that cause API errors during creation + active, + tags, + // Keep everything else + ...cleanedWorkflow + } = workflow; + + // Ensure settings are present with defaults + if (!cleanedWorkflow.settings) { + cleanedWorkflow.settings = defaultWorkflowSettings; + } + + return cleanedWorkflow; +} + +export function cleanWorkflowForUpdate(workflow: Workflow): Partial { + const { + // Remove read-only/computed fields + id, + createdAt, + updatedAt, + versionId, + meta, + staticData, + // Remove fields that cause API errors + pinData, + tags, + // Keep everything else + ...cleanedWorkflow + } = workflow as any; + + // Ensure settings are present + if (!cleanedWorkflow.settings) { + cleanedWorkflow.settings = defaultWorkflowSettings; + } + + return cleanedWorkflow; +} + +// Validate workflow structure +export function validateWorkflowStructure(workflow: Partial): string[] { + const errors: string[] = []; + + // Check required fields + if (!workflow.name) { + errors.push('Workflow name is required'); + } + + if (!workflow.nodes || workflow.nodes.length === 0) { + errors.push('Workflow must have at least one node'); + } + + if (!workflow.connections) { + errors.push('Workflow connections are required'); + } + + // Validate nodes + if (workflow.nodes) { + workflow.nodes.forEach((node, index) => { + try { + validateWorkflowNode(node); + } catch (error) { + errors.push(`Invalid node at index ${index}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }); + } + + // Validate connections + if (workflow.connections) { + try { + validateWorkflowConnections(workflow.connections); + } catch (error) { + errors.push(`Invalid connections: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Validate that all connection references exist + if (workflow.nodes && workflow.connections) { + const nodeIds = new Set(workflow.nodes.map(node => node.id)); + + Object.entries(workflow.connections).forEach(([sourceId, connection]) => { + if (!nodeIds.has(sourceId)) { + errors.push(`Connection references non-existent source node: ${sourceId}`); + } + + connection.main.forEach((outputs, outputIndex) => { + outputs.forEach((target, targetIndex) => { + if (!nodeIds.has(target.node)) { + errors.push(`Connection references non-existent target node: ${target.node} (from ${sourceId}[${outputIndex}][${targetIndex}])`); + } + }); + }); + }); + } + + return errors; +} + +// Check if workflow has webhook trigger +export function hasWebhookTrigger(workflow: Workflow): boolean { + return workflow.nodes.some(node => + node.type === 'n8n-nodes-base.webhook' || + node.type === 'n8n-nodes-base.webhookTrigger' + ); +} + +// Get webhook URL from workflow +export function getWebhookUrl(workflow: Workflow): string | null { + const webhookNode = workflow.nodes.find(node => + node.type === 'n8n-nodes-base.webhook' || + node.type === 'n8n-nodes-base.webhookTrigger' + ); + + if (!webhookNode || !webhookNode.parameters) { + return null; + } + + // Check for path parameter + const path = webhookNode.parameters.path as string | undefined; + if (!path) { + return null; + } + + // Note: We can't construct the full URL without knowing the n8n instance URL + // The caller will need to prepend the base URL + return path; +} \ No newline at end of file diff --git a/src/types/n8n-api.ts b/src/types/n8n-api.ts new file mode 100644 index 0000000..24a778b --- /dev/null +++ b/src/types/n8n-api.ts @@ -0,0 +1,281 @@ +// n8n API Types - Ported from n8n-manager-for-ai-agents +// These types define the structure of n8n API requests and responses + +// Workflow Node Types +export interface WorkflowNode { + id: string; + name: string; + type: string; + typeVersion: number; + position: [number, number]; + parameters: Record; + credentials?: Record; + disabled?: boolean; + notes?: string; + notesInFlow?: boolean; + continueOnFail?: boolean; + retryOnFail?: boolean; + maxTries?: number; + waitBetweenTries?: number; + alwaysOutputData?: boolean; + executeOnce?: boolean; +} + +export interface WorkflowConnection { + [sourceNodeId: string]: { + main: Array>; + }; +} + +export interface WorkflowSettings { + executionOrder?: 'v0' | 'v1'; + timezone?: string; + saveDataErrorExecution?: 'all' | 'none'; + saveDataSuccessExecution?: 'all' | 'none'; + saveManualExecutions?: boolean; + saveExecutionProgress?: boolean; + executionTimeout?: number; + errorWorkflow?: string; +} + +export interface Workflow { + id?: string; + name: string; + nodes: WorkflowNode[]; + connections: WorkflowConnection; + active?: boolean; // Optional for creation as it's read-only + settings?: WorkflowSettings; + staticData?: Record; + tags?: string[]; + updatedAt?: string; + createdAt?: string; + versionId?: string; + meta?: { + instanceId?: string; + }; +} + +// Execution Types +export enum ExecutionStatus { + SUCCESS = 'success', + ERROR = 'error', + WAITING = 'waiting', + // Note: 'running' status is not returned by the API +} + +export interface ExecutionSummary { + id: string; + finished: boolean; + mode: string; + retryOf?: string; + retrySuccessId?: string; + status: ExecutionStatus; + startedAt: string; + stoppedAt?: string; + workflowId: string; + workflowName?: string; + waitTill?: string; +} + +export interface ExecutionData { + startData?: Record; + resultData: { + runData: Record; + lastNodeExecuted?: string; + error?: Record; + }; + executionData?: Record; +} + +export interface Execution extends ExecutionSummary { + data?: ExecutionData; +} + +// Credential Types +export interface Credential { + id?: string; + name: string; + type: string; + data?: Record; + nodesAccess?: Array<{ + nodeType: string; + date?: string; + }>; + createdAt?: string; + updatedAt?: string; +} + +// Tag Types +export interface Tag { + id?: string; + name: string; + workflowIds?: string[]; + createdAt?: string; + updatedAt?: string; +} + +// Variable Types +export interface Variable { + id?: string; + key: string; + value: string; + type?: 'string'; +} + +// Import/Export Types +export interface WorkflowExport { + id: string; + name: string; + active: boolean; + createdAt: string; + updatedAt: string; + nodes: WorkflowNode[]; + connections: WorkflowConnection; + settings?: WorkflowSettings; + staticData?: Record; + tags?: string[]; + pinData?: Record; + versionId?: string; + meta?: Record; +} + +export interface WorkflowImport { + name: string; + nodes: WorkflowNode[]; + connections: WorkflowConnection; + settings?: WorkflowSettings; + staticData?: Record; + tags?: string[]; + pinData?: Record; +} + +// Source Control Types +export interface SourceControlStatus { + ahead: number; + behind: number; + conflicted: string[]; + created: string[]; + current: string; + deleted: string[]; + detached: boolean; + files: Array<{ + path: string; + status: string; + }>; + modified: string[]; + notAdded: string[]; + renamed: Array<{ + from: string; + to: string; + }>; + staged: string[]; + tracking: string; +} + +export interface SourceControlPullResult { + conflicts: string[]; + files: Array<{ + path: string; + status: string; + }>; + mergeConflicts: boolean; + pullResult: 'success' | 'conflict' | 'error'; +} + +export interface SourceControlPushResult { + ahead: number; + conflicts: string[]; + files: Array<{ + path: string; + status: string; + }>; + pushResult: 'success' | 'conflict' | 'error'; +} + +// Health Check Types +export interface HealthCheckResponse { + status: 'ok' | 'error'; + instanceId?: string; + n8nVersion?: string; + features?: { + sourceControl?: boolean; + externalHooks?: boolean; + workers?: boolean; + [key: string]: boolean | undefined; + }; +} + +// Request Parameter Types +export interface WorkflowListParams { + limit?: number; + cursor?: string; + active?: boolean; + tags?: string[] | null; + projectId?: string; + excludePinnedData?: boolean; + instance?: string; +} + +export interface WorkflowListResponse { + data: Workflow[]; + nextCursor?: string | null; +} + +export interface ExecutionListParams { + limit?: number; + cursor?: string; + workflowId?: string; + projectId?: string; + status?: ExecutionStatus; + includeData?: boolean; +} + +export interface ExecutionListResponse { + data: Execution[]; + nextCursor?: string | null; +} + +export interface CredentialListParams { + limit?: number; + cursor?: string; + filter?: Record; +} + +export interface CredentialListResponse { + data: Credential[]; + nextCursor?: string | null; +} + +export interface TagListParams { + limit?: number; + cursor?: string; + withUsageCount?: boolean; +} + +export interface TagListResponse { + data: Tag[]; + nextCursor?: string | null; +} + +// Webhook Request Type +export interface WebhookRequest { + webhookUrl: string; + httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE'; + data?: Record; + headers?: Record; + waitForResponse?: boolean; +} + +// MCP Tool Response Type +export interface McpToolResponse { + success: boolean; + data?: unknown; + error?: string; + message?: string; + code?: string; + details?: Record; +} \ No newline at end of file diff --git a/src/utils/n8n-errors.ts b/src/utils/n8n-errors.ts new file mode 100644 index 0000000..7b6a27d --- /dev/null +++ b/src/utils/n8n-errors.ts @@ -0,0 +1,136 @@ +import { logger } from './logger'; + +// Custom error classes for n8n API operations + +export class N8nApiError extends Error { + constructor( + message: string, + public statusCode?: number, + public code?: string, + public details?: unknown + ) { + super(message); + this.name = 'N8nApiError'; + } +} + +export class N8nAuthenticationError extends N8nApiError { + constructor(message = 'Authentication failed') { + super(message, 401, 'AUTHENTICATION_ERROR'); + this.name = 'N8nAuthenticationError'; + } +} + +export class N8nNotFoundError extends N8nApiError { + constructor(resource: string, id?: string) { + const message = id ? `${resource} with ID ${id} not found` : `${resource} not found`; + super(message, 404, 'NOT_FOUND'); + this.name = 'N8nNotFoundError'; + } +} + +export class N8nValidationError extends N8nApiError { + constructor(message: string, details?: unknown) { + super(message, 400, 'VALIDATION_ERROR', details); + this.name = 'N8nValidationError'; + } +} + +export class N8nRateLimitError extends N8nApiError { + constructor(retryAfter?: number) { + const message = retryAfter + ? `Rate limit exceeded. Retry after ${retryAfter} seconds` + : 'Rate limit exceeded'; + super(message, 429, 'RATE_LIMIT_ERROR', { retryAfter }); + this.name = 'N8nRateLimitError'; + } +} + +export class N8nServerError extends N8nApiError { + constructor(message = 'Internal server error', statusCode = 500) { + super(message, statusCode, 'SERVER_ERROR'); + this.name = 'N8nServerError'; + } +} + +// Error handling utility +export function handleN8nApiError(error: unknown): N8nApiError { + if (error instanceof N8nApiError) { + return error; + } + + if (error instanceof Error) { + // Check if it's an Axios error + const axiosError = error as any; + if (axiosError.response) { + const { status, data } = axiosError.response; + const message = data?.message || axiosError.message; + + switch (status) { + case 401: + return new N8nAuthenticationError(message); + case 404: + return new N8nNotFoundError('Resource', message); + case 400: + return new N8nValidationError(message, data); + case 429: + const retryAfter = axiosError.response.headers['retry-after']; + return new N8nRateLimitError(retryAfter ? parseInt(retryAfter) : undefined); + default: + if (status >= 500) { + return new N8nServerError(message, status); + } + return new N8nApiError(message, status, 'API_ERROR', data); + } + } else if (axiosError.request) { + // Request was made but no response received + return new N8nApiError('No response from n8n server', undefined, 'NO_RESPONSE'); + } else { + // Something happened in setting up the request + return new N8nApiError(axiosError.message, undefined, 'REQUEST_ERROR'); + } + } + + // Unknown error type + return new N8nApiError('Unknown error occurred', undefined, 'UNKNOWN_ERROR', error); +} + +// Utility to extract user-friendly error messages +export function getUserFriendlyErrorMessage(error: N8nApiError): string { + switch (error.code) { + case 'AUTHENTICATION_ERROR': + return 'Failed to authenticate with n8n. Please check your API key.'; + case 'NOT_FOUND': + return error.message; + case 'VALIDATION_ERROR': + return `Invalid request: ${error.message}`; + case 'RATE_LIMIT_ERROR': + return 'Too many requests. Please wait a moment and try again.'; + case 'NO_RESPONSE': + return 'Unable to connect to n8n. Please check the server URL and ensure n8n is running.'; + case 'SERVER_ERROR': + return 'n8n server error. Please try again later or contact support.'; + default: + return error.message || 'An unexpected error occurred'; + } +} + +// Log error with appropriate level +export function logN8nError(error: N8nApiError, context?: string): void { + const errorInfo = { + name: error.name, + message: error.message, + code: error.code, + statusCode: error.statusCode, + details: error.details, + context, + }; + + if (error.statusCode && error.statusCode >= 500) { + logger.error('n8n API server error', errorInfo); + } else if (error.statusCode && error.statusCode >= 400) { + logger.warn('n8n API client error', errorInfo); + } else { + logger.error('n8n API error', errorInfo); + } +} \ No newline at end of file