fix: n8n_test_workflow webhookId resolution and form handling (v2.28.2)

## Bug Fixes

- **webhookId Resolution**: Fixed trigger handlers using `node.id` instead of
  `node.webhookId` for building webhook URLs. This caused 404 errors when
  nodes had custom IDs.

- **Chat Trigger URL**: Fixed chat triggers using wrong URL pattern. Now
  correctly uses `/webhook/<webhookId>/chat` endpoint.

- **Form Content-Type**: Fixed form triggers failing with "Expected
  multipart/form-data" error by switching to proper multipart encoding.

## Enhancements

- **Form Field Types**: Added support for all n8n form field types (text,
  textarea, email, number, password, date, dropdown, checkbox, file, hidden)

- **Checkbox Arrays**: Automatically converts arrays to `field[]` format

- **Helpful Warnings**: Reports missing required fields with names and labels

- **Error Hints**: Provides complete field structure on failure

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2025-12-01 21:56:19 +01:00
parent 3188d209b7
commit 3cbb02650b
5 changed files with 339 additions and 20 deletions

View File

@@ -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/<id>/chat` suffix.
- **Root Cause**: `buildTriggerUrl()` used same pattern for webhooks and chat triggers
- **Fix**: Chat triggers now correctly use `/webhook/<webhookId>/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

View File

@@ -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",

View File

@@ -2,14 +2,15 @@
* Form trigger handler
*
* Handles form-based workflow triggers:
* - POST to /form/<workflowId> or /form-test/<workflowId>
* - Passes form fields as request body
* - POST to /form/<webhookId> 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<string, unknown> | 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<FormTriggerInput> {
): Promise<TriggerResponse> {
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/<path> endpoint
// The path can be from trigger info or workflow ID
const formPath = triggerInfo?.node?.parameters?.path || input.workflowId;
// Form triggers use /form/<webhookId> 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<FormTriggerInput> {
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<FormTriggerInput> {
// 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<FormTriggerInput> {
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),
},
});
}
}

View File

@@ -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<string, unknown>, nodeId: string): string {
function extractWebhookPath(params: Record<string, unknown>, 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<string, unknown>, 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/<webhookId>/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/<workflowId> endpoint
// Form triggers use /form/<webhookId> 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:

View File

@@ -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 {