diff --git a/CHANGELOG.md b/CHANGELOG.md index 439d849..1af5360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.28.2] - 2025-12-01 + +### Bug Fixes + +**n8n_test_workflow: webhookId Resolution** + +Fixed critical bug where trigger handlers used `node.id` instead of `node.webhookId` for building webhook URLs. This caused chat/form/webhook triggers to fail with 404 errors when nodes had custom IDs. + +- **Root Cause**: `extractWebhookPath()` in `trigger-detector.ts` fell back to `node.id` instead of checking `node.webhookId` first +- **Fix**: Added `webhookId` to `WorkflowNode` type and updated priority: `params.path` > `webhookId` > `node.id` +- **Files**: `src/triggers/trigger-detector.ts`, `src/types/n8n-api.ts` + +**n8n_test_workflow: Chat Trigger URL Pattern** + +Fixed chat triggers using wrong URL pattern. n8n chat triggers require `/webhook//chat` suffix. + +- **Root Cause**: `buildTriggerUrl()` used same pattern for webhooks and chat triggers +- **Fix**: Chat triggers now correctly use `/webhook//chat` endpoint +- **Files**: `src/triggers/trigger-detector.ts:284-289` + +**n8n_test_workflow: Form Trigger Content-Type** + +Fixed form triggers failing with "Expected multipart/form-data" error. + +- **Root Cause**: Form handler sent `application/json` but n8n requires `multipart/form-data` +- **Fix**: Switched to `form-data` library for proper multipart encoding +- **Files**: `src/triggers/handlers/form-handler.ts` + +### Enhancements + +**Form Handler: Complete Field Type Support** + +Enhanced form handler to support all n8n form field types with intelligent handling: + +- **Supported Types**: text, textarea, email, number, password, date, dropdown, checkbox, file, hidden, html +- **Checkbox Arrays**: Automatically converts arrays to `field[]` format required by n8n +- **File Uploads**: Supports base64 content or sends empty placeholder for required files +- **Helpful Warnings**: Reports missing required fields with field names and labels +- **Error Hints**: On failure, provides complete field structure with usage examples + +```javascript +// Example with all field types +n8n_test_workflow({ + workflowId: "abc123", + data: { + "field-0": "text value", + "field-1": ["checkbox1", "checkbox2"], // Array for checkboxes + "field-2": "dropdown_option", + "field-3": "2025-01-15", // Date format + "field-4": "user@example.com", + "field-5": 42, // Number + "field-6": "password123" + } +}) +``` + +**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)** + ## [2.28.1] - 2025-12-01 ### 🐛 Bug Fixes diff --git a/package.json b/package.json index e427b16..b7a0fa7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.28.1", + "version": "2.28.2", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/triggers/handlers/form-handler.ts b/src/triggers/handlers/form-handler.ts index 5fced9c..c282d74 100644 --- a/src/triggers/handlers/form-handler.ts +++ b/src/triggers/handlers/form-handler.ts @@ -2,14 +2,15 @@ * Form trigger handler * * Handles form-based workflow triggers: - * - POST to /form/ or /form-test/ - * - Passes form fields as request body + * - POST to /form/ with multipart/form-data + * - Supports all n8n form field types: text, textarea, email, number, password, date, dropdown, checkbox, file, hidden * - Workflow must be active (for production endpoint) */ import { z } from 'zod'; import axios, { AxiosRequestConfig } from 'axios'; -import { Workflow, WebhookRequest } from '../../types/n8n-api'; +import FormData from 'form-data'; +import { Workflow, WorkflowNode } from '../../types/n8n-api'; import { TriggerType, TriggerResponse, @@ -32,6 +33,123 @@ const formInputSchema = z.object({ waitForResponse: z.boolean().optional(), }); +/** + * Form field definition extracted from workflow + */ +interface FormFieldDef { + index: number; + fieldName: string; // field-0, field-1, etc. + label: string; + type: string; + required: boolean; + options?: string[]; // For dropdown/checkbox +} + +/** + * Extract form field definitions from workflow + */ +function extractFormFields(workflow: Workflow, triggerNode?: WorkflowNode): FormFieldDef[] { + const node = triggerNode || workflow.nodes.find(n => + n.type.toLowerCase().includes('formtrigger') + ); + + const params = node?.parameters as Record | undefined; + const formFields = params?.formFields as { values?: unknown[] } | undefined; + + if (!formFields?.values) { + return []; + } + + const fields: FormFieldDef[] = []; + let fieldIndex = 0; + + for (const field of formFields.values as any[]) { + const fieldType = field.fieldType || 'text'; + + // HTML fields are rendered as hidden inputs but are display-only + // They still get a field index + const def: FormFieldDef = { + index: fieldIndex, + fieldName: `field-${fieldIndex}`, + label: field.fieldLabel || field.fieldName || field.elementName || `field-${fieldIndex}`, + type: fieldType, + required: field.requiredField === true, + }; + + // Extract options for dropdown/checkbox + if (field.fieldOptions?.values) { + def.options = field.fieldOptions.values.map((v: any) => v.option); + } + + fields.push(def); + fieldIndex++; + } + + return fields; +} + +/** + * Generate helpful usage hint for form fields + */ +function generateFormUsageHint(fields: FormFieldDef[]): string { + if (fields.length === 0) { + return 'No form fields detected in workflow.'; + } + + const lines: string[] = ['Form fields (use these keys in data parameter):']; + + for (const field of fields) { + let hint = ` "${field.fieldName}": `; + + switch (field.type) { + case 'checkbox': + hint += `["${field.options?.[0] || 'option1'}", ...]`; + if (field.options) { + hint += ` (options: ${field.options.join(', ')})`; + } + break; + case 'dropdown': + hint += `"${field.options?.[0] || 'value'}"`; + if (field.options) { + hint += ` (options: ${field.options.join(', ')})`; + } + break; + case 'date': + hint += '"YYYY-MM-DD"'; + break; + case 'email': + hint += '"user@example.com"'; + break; + case 'number': + hint += '123'; + break; + case 'file': + hint += '{ filename: "test.txt", content: "base64..." } or skip (sends empty file)'; + break; + case 'password': + hint += '"secret"'; + break; + case 'textarea': + hint += '"multi-line text..."'; + break; + case 'html': + hint += '"" (display-only, can be omitted)'; + break; + case 'hiddenField': + hint += '"value" (hidden field)'; + break; + default: + hint += '"text value"'; + } + + hint += field.required ? ' [REQUIRED]' : ''; + hint += ` // ${field.label}`; + lines.push(hint); + } + + return lines.join('\n'); +} + /** * Form trigger handler */ @@ -52,20 +170,27 @@ export class FormHandler extends BaseTriggerHandler { ): Promise { const startTime = Date.now(); + // Extract form field definitions for helpful error messages + const formFieldDefs = extractFormFields(workflow, triggerInfo?.node); + try { // Build form URL const baseUrl = this.getBaseUrl(); if (!baseUrl) { - return this.errorResponse(input, 'Cannot determine n8n base URL', startTime); + return this.errorResponse(input, 'Cannot determine n8n base URL', startTime, { + details: { + formFields: formFieldDefs, + hint: generateFormUsageHint(formFieldDefs), + }, + }); } - // Form triggers use /form/ endpoint - // The path can be from trigger info or workflow ID - const formPath = triggerInfo?.node?.parameters?.path || input.workflowId; + // Form triggers use /form/ endpoint + const formPath = triggerInfo?.webhookPath || triggerInfo?.node?.parameters?.path || input.workflowId; const formUrl = `${baseUrl.replace(/\/+$/, '')}/form/${formPath}`; // Merge formData and data (formData takes precedence) - const formFields = { + const inputFields = { ...input.data, ...input.formData, }; @@ -77,15 +202,104 @@ export class FormHandler extends BaseTriggerHandler { return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime); } + // Build multipart/form-data (required by n8n form triggers) + const formData = new FormData(); + const warnings: string[] = []; + + // Process each defined form field + for (const fieldDef of formFieldDefs) { + const value = inputFields[fieldDef.fieldName]; + + switch (fieldDef.type) { + case 'checkbox': + // Checkbox fields need array syntax with [] suffix + if (Array.isArray(value)) { + for (const item of value) { + formData.append(`${fieldDef.fieldName}[]`, String(item ?? '')); + } + } else if (value !== undefined && value !== null) { + // Single value provided, wrap in array + formData.append(`${fieldDef.fieldName}[]`, String(value)); + } else if (fieldDef.required) { + warnings.push(`Required checkbox field "${fieldDef.fieldName}" (${fieldDef.label}) not provided`); + } + break; + + case 'file': + // File fields - handle file upload or send empty placeholder + if (value && typeof value === 'object' && 'content' in value) { + // File object with content (base64 or buffer) + const fileObj = value as { filename?: string; content: string | Buffer }; + const buffer = typeof fileObj.content === 'string' + ? Buffer.from(fileObj.content, 'base64') + : fileObj.content; + formData.append(fieldDef.fieldName, buffer, { + filename: fileObj.filename || 'file.txt', + contentType: 'application/octet-stream', + }); + } else if (value && typeof value === 'string') { + // String value - treat as base64 content + formData.append(fieldDef.fieldName, Buffer.from(value, 'base64'), { + filename: 'file.txt', + contentType: 'application/octet-stream', + }); + } else { + // No file provided - send empty file as placeholder + formData.append(fieldDef.fieldName, Buffer.from(''), { + filename: 'empty.txt', + contentType: 'text/plain', + }); + if (fieldDef.required) { + warnings.push(`Required file field "${fieldDef.fieldName}" (${fieldDef.label}) not provided - sending empty placeholder`); + } + } + break; + + case 'html': + // HTML is display-only, but n8n renders it as hidden input + // Send empty string or provided value + formData.append(fieldDef.fieldName, String(value ?? '')); + break; + + case 'hiddenField': + // Hidden fields + formData.append(fieldDef.fieldName, String(value ?? '')); + break; + + default: + // Standard fields: text, textarea, email, number, password, date, dropdown + if (value !== undefined && value !== null) { + formData.append(fieldDef.fieldName, String(value)); + } else if (fieldDef.required) { + warnings.push(`Required field "${fieldDef.fieldName}" (${fieldDef.label}) not provided`); + } + break; + } + } + + // Also include any extra fields not in the form definition (for flexibility) + const definedFieldNames = new Set(formFieldDefs.map(f => f.fieldName)); + for (const [key, value] of Object.entries(inputFields)) { + if (!definedFieldNames.has(key)) { + if (Array.isArray(value)) { + for (const item of value) { + formData.append(`${key}[]`, String(item ?? '')); + } + } else { + formData.append(key, String(value ?? '')); + } + } + } + // Build request config const config: AxiosRequestConfig = { method: 'POST', url: formUrl, headers: { - 'Content-Type': 'application/json', + ...formData.getHeaders(), ...input.headers, }, - data: formFields, + data: formData, timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000), validateStatus: (status) => status < 500, }; @@ -93,13 +307,29 @@ export class FormHandler extends BaseTriggerHandler { // Make the request const response = await axios.request(config); - return this.normalizeResponse(response.data, input, startTime, { + const result = this.normalizeResponse(response.data, input, startTime, { status: response.status, statusText: response.statusText, metadata: { duration: Date.now() - startTime, }, }); + + // Add fields submitted count to details + result.details = { + ...result.details, + fieldsSubmitted: formFieldDefs.length, + }; + + // Add warnings if any + if (warnings.length > 0) { + result.details = { + ...result.details, + warnings, + }; + } + + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -110,7 +340,17 @@ export class FormHandler extends BaseTriggerHandler { return this.errorResponse(input, errorMessage, startTime, { executionId, code: (error as any)?.code, - details: errorDetails, + details: { + ...errorDetails, + formFields: formFieldDefs.map(f => ({ + name: f.fieldName, + label: f.label, + type: f.type, + required: f.required, + options: f.options, + })), + hint: generateFormUsageHint(formFieldDefs), + }, }); } } diff --git a/src/triggers/trigger-detector.ts b/src/triggers/trigger-detector.ts index b90b6c2..d6afcb2 100644 --- a/src/triggers/trigger-detector.ts +++ b/src/triggers/trigger-detector.ts @@ -119,7 +119,7 @@ function detectWebhookTrigger(node: WorkflowNode): DetectedTrigger | null { // Extract webhook path from parameters const params = node.parameters || {}; - const webhookPath = extractWebhookPath(params, node.id); + const webhookPath = extractWebhookPath(params, node.id, node.webhookId); const httpMethod = extractHttpMethod(params); return { @@ -148,10 +148,12 @@ function detectFormTrigger(node: WorkflowNode): DetectedTrigger | null { // Extract form fields from parameters const params = node.parameters || {}; const formFields = extractFormFields(params); + const webhookPath = extractWebhookPath(params, node.id, node.webhookId); return { type: 'form', node, + webhookPath, formFields, }; } @@ -174,7 +176,7 @@ function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null { // Extract chat configuration const params = node.parameters || {}; const responseMode = (params.options as any)?.responseMode || 'lastNode'; - const webhookPath = extractWebhookPath(params, node.id); + const webhookPath = extractWebhookPath(params, node.id, node.webhookId); return { type: 'chat', @@ -188,8 +190,14 @@ function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null { /** * Extract webhook path from node parameters + * + * Priority: + * 1. Explicit path parameter in node config + * 2. HTTP method specific path + * 3. webhookId on the node (n8n assigns this for all webhook-like triggers) + * 4. Fallback to node ID */ -function extractWebhookPath(params: Record, nodeId: string): string { +function extractWebhookPath(params: Record, nodeId: string, webhookId?: string): string { // Check for explicit path parameter if (typeof params.path === 'string' && params.path) { return params.path; @@ -203,6 +211,11 @@ function extractWebhookPath(params: Record, nodeId: string): st } } + // Use webhookId if available (n8n assigns this for chat/form/webhook triggers) + if (typeof webhookId === 'string' && webhookId) { + return webhookId; + } + // Default: use node ID as path (n8n default behavior) return nodeId; } @@ -262,17 +275,24 @@ export function buildTriggerUrl( const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); // Remove trailing slashes switch (trigger.type) { - case 'webhook': - case 'chat': { + case 'webhook': { const prefix = mode === 'test' ? 'webhook-test' : 'webhook'; const path = trigger.webhookPath || trigger.node.id; return `${cleanBaseUrl}/${prefix}/${path}`; } + case 'chat': { + // Chat triggers use /webhook//chat endpoint + const prefix = mode === 'test' ? 'webhook-test' : 'webhook'; + const path = trigger.webhookPath || trigger.node.id; + return `${cleanBaseUrl}/${prefix}/${path}/chat`; + } + case 'form': { - // Form triggers use /form/ endpoint + // Form triggers use /form/ endpoint const prefix = mode === 'test' ? 'form-test' : 'form'; - return `${cleanBaseUrl}/${prefix}/${trigger.node.id}`; + const path = trigger.webhookPath || trigger.node.id; + return `${cleanBaseUrl}/${prefix}/${path}`; } default: diff --git a/src/types/n8n-api.ts b/src/types/n8n-api.ts index be54d28..66c7254 100644 --- a/src/types/n8n-api.ts +++ b/src/types/n8n-api.ts @@ -30,6 +30,7 @@ export interface WorkflowNode { waitBetweenTries?: number; alwaysOutputData?: boolean; executeOnce?: boolean; + webhookId?: string; // n8n assigns this for webhook/form/chat trigger nodes } export interface WorkflowConnection {