Compare commits

..

2 Commits

Author SHA1 Message Date
czlonkowski
10a3cdcaca test: add missing test coverage for PR #461 improvements
- Added test for AI Agent validation positive case (tools properly connected)
- Added 3 tests for expectedFormat on resourceLocator properties

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>
2025-12-01 18:40:51 +01:00
czlonkowski
f2e2d704fb fix: AI connection type propagation and get_node improvements (v2.28.1)
Bug fixes:
- Issue #458: addConnection now preserves AI connection types (ai_tool, ai_memory, ai_languageModel) instead of defaulting to 'main'
- Fixed false positive "AI Agent has no tools connected" validation warning

Enhancements:
- Added expectedFormat field to resourceLocator properties in get_node output
- Added versionNotice field to make typeVersion more prominent

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>
2025-12-01 16:50:45 +01:00
16 changed files with 4011 additions and 6663 deletions

View File

@@ -52,9 +52,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

View File

@@ -7,102 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.28.4] - 2025-12-05
### Features
**Configurable MAX_SESSIONS Limit (#468)**
The `MAX_SESSIONS` limit is now configurable via the `N8N_MCP_MAX_SESSIONS` environment variable, addressing scalability issues for multi-tenant SaaS deployments.
- **Problem**: Hardcoded limit of 100 concurrent sessions caused "Session limit reached" errors during peak usage
- **Solution**: `MAX_SESSIONS` now reads from `N8N_MCP_MAX_SESSIONS` env var (default: 100)
- **Usage**: Set `N8N_MCP_MAX_SESSIONS=1000` for higher capacity deployments
- **Safety**: Includes `Math.max(1, ...)` floor to prevent invalid configurations
- **Files**: `src/http-server-single-session.ts:44`
```bash
# Example: Allow up to 1000 concurrent sessions
N8N_MCP_MAX_SESSIONS=1000
```
## [2.28.3] - 2025-12-02
### Changed
**Dependencies**
- Updated n8n from 1.121.2 to 1.122.4
- Updated n8n-core from 1.120.1 to 1.121.1
- Updated n8n-workflow from 1.118.1 to 1.119.1
- Updated @n8n/n8n-nodes-langchain from 1.120.1 to 1.121.1
- Rebuilt node database with 544 nodes (438 from n8n-nodes-base, 106 from @n8n/n8n-nodes-langchain)
### Removed
**Templates**
- Removed 7 templates from creator "ludwig" at author's request
- Template IDs: 2795, 2816, 2825, 2850, 2869, 2939, 3847
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
## [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
@@ -615,7 +519,7 @@ Added export/restore functionality for MCP sessions to enable zero-downtime depl
- `restoreSessionState(sessions)` method for session recovery
- Validates session structure using existing `validateInstanceContext()`
- Handles null/invalid sessions gracefully with warnings
- Enforces MAX_SESSIONS limit (default 100, configurable via N8N_MCP_MAX_SESSIONS env var)
- Enforces MAX_SESSIONS limit (100 concurrent sessions)
- Skips expired sessions during restore
**3. SessionState Type**

View File

@@ -209,7 +209,7 @@ The MCP server exposes tools in several categories:
- **Security-first**: API keys exported as plaintext - downstream MUST encrypt
- **Dormant sessions**: Restored sessions recreate transports on first request
- **Automatic expiration**: Respects `sessionTimeout` setting (default 30 min)
- **MAX_SESSIONS limit**: Caps at 100 concurrent sessions (configurable via N8N_MCP_MAX_SESSIONS env var)
- **MAX_SESSIONS limit**: Caps at 100 concurrent sessions
**Important Implementation Notes:**
- Only exports sessions with valid n8nApiUrl and n8nApiKey in context

View File

@@ -5,7 +5,7 @@
[![npm version](https://img.shields.io/npm/v/n8n-mcp.svg)](https://www.npmjs.com/package/n8n-mcp)
[![codecov](https://codecov.io/gh/czlonkowski/n8n-mcp/graph/badge.svg?token=YOUR_TOKEN)](https://codecov.io/gh/czlonkowski/n8n-mcp)
[![Tests](https://img.shields.io/badge/tests-3336%20passing-brightgreen.svg)](https://github.com/czlonkowski/n8n-mcp/actions)
[![n8n version](https://img.shields.io/badge/n8n-1.122.4-orange.svg)](https://github.com/n8n-io/n8n)
[![n8n version](https://img.shields.io/badge/n8n-1.121.2-orange.svg)](https://github.com/n8n-io/n8n)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fczlonkowski%2Fn8n--mcp-green.svg)](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)

Binary file not shown.

View File

@@ -558,7 +558,7 @@ DISABLE_CONSOLE_OUTPUT=false
# Optional: Session configuration
SESSION_TIMEOUT=1800000 # 30 minutes in milliseconds
N8N_MCP_MAX_SESSIONS=100 # Maximum concurrent sessions (default: 100)
MAX_SESSIONS=100
# Optional: Performance
NODE_ENV=production

View File

@@ -93,7 +93,7 @@ console.log(`Restored ${count} sessions`);
- Validates session metadata (timestamps, required fields)
- Skips expired sessions (age > sessionTimeout)
- Skips duplicate sessions (idempotent)
- Respects MAX_SESSIONS limit (default 100, configurable via N8N_MCP_MAX_SESSIONS env var)
- Respects MAX_SESSIONS limit (100 per container)
- Recreates transports/servers lazily on first request
- Logs security events for restore success/failure
@@ -595,19 +595,19 @@ console.log(`Export size: ${sizeKB.toFixed(2)} KB`);
### MAX_SESSIONS Limit
Default limit: 100 sessions per container (configurable via `N8N_MCP_MAX_SESSIONS` env var)
Hard limit: 100 sessions per container
```typescript
// Restore respects limit
const sessions = createSessions(150); // 150 sessions
const restored = engine.restoreSessionState(sessions);
// restored = 100 (only first 100 restored, or N8N_MCP_MAX_SESSIONS value)
// restored = 100 (only first 100 restored)
```
For higher session limits:
- Set `N8N_MCP_MAX_SESSIONS=1000` (or desired limit)
- Monitor memory usage as sessions consume resources
- Alternatively, deploy multiple containers with session routing/sharding
For >100 sessions per tenant:
- Deploy multiple containers
- Use session routing/sharding
- Implement session affinity
## Troubleshooting
@@ -676,11 +676,10 @@ Reached MAX_SESSIONS limit (100), skipping remaining sessions
**Solutions:**
1. Increase limit: Set `N8N_MCP_MAX_SESSIONS=1000` (or desired value)
2. Scale horizontally (more containers)
3. Implement session sharding
4. Reduce sessionTimeout
5. Clean up inactive sessions
1. Scale horizontally (more containers)
2. Implement session sharding
3. Reduce sessionTimeout
4. Clean up inactive sessions
```typescript
// Pre-filter by activity

10042
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.28.4",
"version": "2.28.1",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -141,16 +141,15 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.20.1",
"@n8n/n8n-nodes-langchain": "^1.121.1",
"@n8n/n8n-nodes-langchain": "^1.120.1",
"@supabase/supabase-js": "^2.57.4",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"express-rate-limit": "^7.1.5",
"form-data": "^4.0.5",
"lru-cache": "^11.2.1",
"n8n": "^1.122.4",
"n8n-core": "^1.121.1",
"n8n-workflow": "^1.119.1",
"n8n": "^1.121.2",
"n8n-core": "^1.120.1",
"n8n-workflow": "^1.118.1",
"openai": "^4.77.0",
"sql.js": "^1.13.0",
"tslib": "^2.6.2",

View File

@@ -41,7 +41,7 @@ interface MultiTenantHeaders {
}
// Session management constants
const MAX_SESSIONS = Math.max(1, parseInt(process.env.N8N_MCP_MAX_SESSIONS || '100', 10));
const MAX_SESSIONS = 100;
const SESSION_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
interface Session {

View File

@@ -2,15 +2,14 @@
* Form trigger handler
*
* Handles form-based workflow triggers:
* - POST to /form/<webhookId> with multipart/form-data
* - Supports all n8n form field types: text, textarea, email, number, password, date, dropdown, checkbox, file, hidden
* - POST to /form/<workflowId> or /form-test/<workflowId>
* - Passes form fields as request body
* - Workflow must be active (for production endpoint)
*/
import { z } from 'zod';
import axios, { AxiosRequestConfig } from 'axios';
import FormData from 'form-data';
import { Workflow, WorkflowNode } from '../../types/n8n-api';
import { Workflow, WebhookRequest } from '../../types/n8n-api';
import {
TriggerType,
TriggerResponse,
@@ -33,188 +32,6 @@ const formInputSchema = z.object({
waitForResponse: z.boolean().optional(),
});
/**
* Form field types supported by n8n
*/
const FORM_FIELD_TYPES = {
TEXT: 'text',
TEXTAREA: 'textarea',
EMAIL: 'email',
NUMBER: 'number',
PASSWORD: 'password',
DATE: 'date',
DROPDOWN: 'dropdown',
CHECKBOX: 'checkbox',
FILE: 'file',
HIDDEN: 'hiddenField',
HTML: 'html',
} as const;
/**
* Maximum file size for base64 uploads (10MB)
*/
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
/**
* n8n form field option structure
*/
interface FormFieldOption {
option: string;
}
/**
* n8n form field value structure from workflow parameters
*/
interface FormFieldValue {
fieldType?: string;
fieldLabel?: string;
fieldName?: string;
elementName?: string;
requiredField?: boolean;
fieldOptions?: {
values?: FormFieldOption[];
};
}
/**
* 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
}
/**
* Check if a string is valid base64
*/
function isValidBase64(str: string): boolean {
if (!str || str.length === 0) {
return false;
}
// Check for valid base64 characters and proper padding
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(str)) {
return false;
}
try {
// Verify round-trip encoding
const decoded = Buffer.from(str, 'base64');
return decoded.toString('base64') === str;
} catch {
return false;
}
}
/**
* 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 rawField of formFields.values) {
const field = rawField as FormFieldValue;
const fieldType = field.fieldType || FORM_FIELD_TYPES.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: FormFieldOption) => 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 FORM_FIELD_TYPES.CHECKBOX:
hint += `["${field.options?.[0] || 'option1'}", ...]`;
if (field.options) {
hint += ` (options: ${field.options.join(', ')})`;
}
break;
case FORM_FIELD_TYPES.DROPDOWN:
hint += `"${field.options?.[0] || 'value'}"`;
if (field.options) {
hint += ` (options: ${field.options.join(', ')})`;
}
break;
case FORM_FIELD_TYPES.DATE:
hint += '"YYYY-MM-DD"';
break;
case FORM_FIELD_TYPES.EMAIL:
hint += '"user@example.com"';
break;
case FORM_FIELD_TYPES.NUMBER:
hint += '123';
break;
case FORM_FIELD_TYPES.FILE:
hint += '{ filename: "test.txt", content: "base64..." } or skip (sends empty file)';
break;
case FORM_FIELD_TYPES.PASSWORD:
hint += '"secret"';
break;
case FORM_FIELD_TYPES.TEXTAREA:
hint += '"multi-line text..."';
break;
case FORM_FIELD_TYPES.HTML:
hint += '"" (display-only, can be omitted)';
break;
case FORM_FIELD_TYPES.HIDDEN:
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
*/
@@ -235,27 +52,20 @@ 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, {
details: {
formFields: formFieldDefs,
hint: generateFormUsageHint(formFieldDefs),
},
});
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
}
// Form triggers use /form/<webhookId> endpoint
const formPath = triggerInfo?.webhookPath || triggerInfo?.node?.parameters?.path || input.workflowId;
// Form triggers use /form/<path> endpoint
// The path can be from trigger info or workflow ID
const formPath = triggerInfo?.node?.parameters?.path || input.workflowId;
const formUrl = `${baseUrl.replace(/\/+$/, '')}/form/${formPath}`;
// Merge formData and data (formData takes precedence)
const inputFields = {
const formFields = {
...input.data,
...input.formData,
};
@@ -267,142 +77,15 @@ 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 FORM_FIELD_TYPES.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 FORM_FIELD_TYPES.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 };
let buffer: Buffer;
if (typeof fileObj.content === 'string') {
// Validate base64 encoding
if (!isValidBase64(fileObj.content)) {
warnings.push(`Invalid base64 encoding for file field "${fieldDef.fieldName}" (${fieldDef.label})`);
buffer = Buffer.from('');
} else {
buffer = Buffer.from(fileObj.content, 'base64');
// Check file size
if (buffer.length > MAX_FILE_SIZE_BYTES) {
warnings.push(`File too large for "${fieldDef.fieldName}" (${fieldDef.label}): ${Math.round(buffer.length / 1024 / 1024)}MB exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit`);
buffer = Buffer.from('');
}
}
} else {
buffer = fileObj.content;
// Check file size for Buffer input
if (buffer.length > MAX_FILE_SIZE_BYTES) {
warnings.push(`File too large for "${fieldDef.fieldName}" (${fieldDef.label}): ${Math.round(buffer.length / 1024 / 1024)}MB exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit`);
buffer = Buffer.from('');
}
}
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
if (!isValidBase64(value)) {
warnings.push(`Invalid base64 encoding for file field "${fieldDef.fieldName}" (${fieldDef.label})`);
formData.append(fieldDef.fieldName, Buffer.from(''), {
filename: 'empty.txt',
contentType: 'text/plain',
});
} else {
const buffer = Buffer.from(value, 'base64');
if (buffer.length > MAX_FILE_SIZE_BYTES) {
warnings.push(`File too large for "${fieldDef.fieldName}" (${fieldDef.label}): ${Math.round(buffer.length / 1024 / 1024)}MB exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit`);
formData.append(fieldDef.fieldName, Buffer.from(''), {
filename: 'empty.txt',
contentType: 'text/plain',
});
} else {
formData.append(fieldDef.fieldName, buffer, {
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 FORM_FIELD_TYPES.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 FORM_FIELD_TYPES.HIDDEN:
// 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: {
...formData.getHeaders(),
'Content-Type': 'application/json',
...input.headers,
},
data: formData,
data: formFields,
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
validateStatus: (status) => status < 500,
};
@@ -410,29 +93,13 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
// Make the request
const response = await axios.request(config);
const result = this.normalizeResponse(response.data, input, startTime, {
return 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';
@@ -443,17 +110,7 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
return this.errorResponse(input, errorMessage, startTime, {
executionId,
code: (error as any)?.code,
details: {
...errorDetails,
formFields: formFieldDefs.map(f => ({
name: f.fieldName,
label: f.label,
type: f.type,
required: f.required,
options: f.options,
})),
hint: generateFormUsageHint(formFieldDefs),
},
details: errorDetails,
});
}
}

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, node.webhookId);
const webhookPath = extractWebhookPath(params, node.id);
const httpMethod = extractHttpMethod(params);
return {
@@ -148,12 +148,10 @@ 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,
};
}
@@ -176,7 +174,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, node.webhookId);
const webhookPath = extractWebhookPath(params, node.id);
return {
type: 'chat',
@@ -190,14 +188,8 @@ 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, webhookId?: string): string {
function extractWebhookPath(params: Record<string, unknown>, nodeId: string): string {
// Check for explicit path parameter
if (typeof params.path === 'string' && params.path) {
return params.path;
@@ -211,11 +203,6 @@ function extractWebhookPath(params: Record<string, unknown>, nodeId: string, web
}
}
// 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;
}
@@ -275,24 +262,17 @@ export function buildTriggerUrl(
const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); // Remove trailing slashes
switch (trigger.type) {
case 'webhook': {
case 'webhook':
case 'chat': {
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/<webhookId> endpoint
// Form triggers use /form/<workflowId> endpoint
const prefix = mode === 'test' ? 'form-test' : 'form';
const path = trigger.webhookPath || trigger.node.id;
return `${cleanBaseUrl}/${prefix}/${path}`;
return `${cleanBaseUrl}/${prefix}/${trigger.node.id}`;
}
default:

View File

@@ -30,7 +30,6 @@ export interface WorkflowNode {
waitBetweenTries?: number;
alwaysOutputData?: boolean;
executeOnce?: boolean;
webhookId?: string; // n8n assigns this for webhook/form/chat trigger nodes
}
export interface WorkflowConnection {

View File

@@ -427,7 +427,7 @@ describe('SingleSessionHTTPServer - Session Persistence', () => {
});
it('should respect MAX_SESSIONS limit during restore', () => {
// Create 99 existing sessions (MAX_SESSIONS defaults to 100, configurable via N8N_MCP_MAX_SESSIONS env var)
// Create 99 existing sessions (MAX_SESSIONS is 100)
const serverAny = server as any;
const now = new Date();
for (let i = 0; i < 99; i++) {

View File

@@ -8,7 +8,6 @@ import { InstanceContext } from '../../../../src/types/instance-context';
import { Workflow } from '../../../../src/types/n8n-api';
import { DetectedTrigger } from '../../../../src/triggers/types';
import axios from 'axios';
import FormData from 'form-data';
// Mock getN8nApiConfig
vi.mock('../../../../src/config/n8n-api', () => ({
@@ -157,7 +156,7 @@ describe('FormHandler', () => {
});
describe('execute', () => {
it('should execute form with provided formData using multipart/form-data', async () => {
it('should execute form with provided formData', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
@@ -179,15 +178,11 @@ describe('FormHandler', () => {
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
})
);
// Verify FormData is used
const config = vi.mocked(axios.request).mock.calls[0][0];
expect(config.data).toBeInstanceOf(FormData);
// Verify multipart/form-data content type is set via FormData headers
expect(config.headers).toEqual(
expect.objectContaining({
'content-type': expect.stringContaining('multipart/form-data'),
data: {
name: 'Jane Doe',
email: 'jane@example.com',
message: 'Hello',
},
})
);
});
@@ -258,9 +253,15 @@ describe('FormHandler', () => {
await handler.execute(input, workflow, triggerInfo);
// Verify FormData is used and contains merged data
const config = vi.mocked(axios.request).mock.calls[0][0];
expect(config.data).toBeInstanceOf(FormData);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
data: {
field1: 'from data',
field2: 'from formData',
field3: 'from formData',
},
})
);
});
it('should return error when base URL not available', async () => {
@@ -302,7 +303,7 @@ describe('FormHandler', () => {
expect(response.error).toContain('Private IP address not allowed');
});
it('should pass custom headers with multipart/form-data', async () => {
it('should pass custom headers', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
@@ -320,13 +321,13 @@ describe('FormHandler', () => {
await handler.execute(input, workflow, triggerInfo);
const config = vi.mocked(axios.request).mock.calls[0][0];
expect(config.headers).toEqual(
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token',
// FormData sets multipart/form-data with boundary
'content-type': expect.stringContaining('multipart/form-data'),
headers: expect.objectContaining({
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
}),
})
);
});
@@ -465,15 +466,10 @@ describe('FormHandler', () => {
expect(response.success).toBe(false);
expect(response.executionId).toBe('exec-111');
// Details include original error data plus form field info and hint
expect(response.details).toEqual(
expect.objectContaining({
id: 'exec-111',
error: 'Validation failed',
formFields: expect.any(Array),
hint: expect.any(String),
})
);
expect(response.details).toEqual({
id: 'exec-111',
error: 'Validation failed',
});
});
it('should handle error with code', async () => {
@@ -539,12 +535,14 @@ describe('FormHandler', () => {
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(true);
// Even empty formData is sent as FormData
const config = vi.mocked(axios.request).mock.calls[0][0];
expect(config.data).toBeInstanceOf(FormData);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
data: {},
})
);
});
it('should handle complex form data types via FormData', async () => {
it('should handle complex form data types', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
@@ -564,9 +562,17 @@ describe('FormHandler', () => {
await handler.execute(input, workflow, triggerInfo);
// Complex data types are serialized in FormData
const config = vi.mocked(axios.request).mock.calls[0][0];
expect(config.data).toBeInstanceOf(FormData);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
data: {
name: 'Test User',
age: 30,
active: true,
tags: ['tag1', 'tag2'],
metadata: { key: 'value' },
},
})
);
});
});
});

View File

@@ -242,7 +242,7 @@ describe('Trigger Detector', () => {
expect(url).toContain('/form/');
});
it('should build chat URL correctly with /chat suffix', () => {
it('should build chat URL correctly', () => {
const baseUrl = 'https://n8n.example.com';
const trigger = {
type: 'chat' as const,
@@ -259,8 +259,7 @@ describe('Trigger Detector', () => {
const url = buildTriggerUrl(baseUrl, trigger, 'production');
// Chat triggers use /webhook/<webhookId>/chat endpoint
expect(url).toBe('https://n8n.example.com/webhook/ai-chat/chat');
expect(url).toBe('https://n8n.example.com/webhook/ai-chat');
});
});