Compare commits

..

7 Commits

Author SHA1 Message Date
Romuald Członkowski
c20bd540cb fix: add QEMU setup for multi-arch Docker builds
Add docker/setup-qemu-action@v3 before Buildx setup to enable
proper QEMU emulation for linux/arm64 builds on GitHub Actions.

Fixes CI Docker build failure with "execve: No such file or directory"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 10:50:23 +01:00
Romuald Członkowski
e344a82a0e Merge main into fix/configurable-max-sessions-468, bump to v2.28.4
- Resolved version conflict: main had 2.28.3 (n8n updates), bumped to 2.28.4
- Merged CHANGELOG entries for both versions
- Updated package-lock.json with new version

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 10:13:17 +01:00
Romuald Członkowski
fd742d551e feat: configurable MAX_SESSIONS via N8N_MCP_MAX_SESSIONS env var (v2.28.3) (#468)
Make MAX_SESSIONS limit configurable for multi-tenant SaaS deployments.

- Add N8N_MCP_MAX_SESSIONS environment variable (default: 100)
- Include safety floor with Math.max(1, ...) to prevent invalid configs
- Update documentation in LIBRARY_USAGE.md, SESSION_PERSISTENCE.md
- Update CLAUDE.md and CHANGELOG.md

Fixes #468

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-05 09:05:22 +01:00
Romuald Członkowski
527e9874ab chore: update n8n to 1.122.4 and remove ludwig's templates (v2.28.3) (#463)
- 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 7 templates from creator "ludwig" at author's request (IDs: 2795, 2816, 2825, 2850, 2869, 2939, 3847)
- Updated README badge with new n8n version
- Updated CHANGELOG with dependency changes

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-03 00:11:41 +01:00
Romuald Członkowski
ef9b6f6341 fix: n8n_test_workflow webhookId resolution and form handling (v2.28.2) (#462) 2025-12-01 22:33:25 +01:00
czlonkowski
f65514381f fix: address code review issues for form trigger improvements
- Add form-data as direct dependency (was only in devDependencies)
- Add TypeScript interfaces (FormFieldValue, FormFieldOption) replacing any types
- Add FORM_FIELD_TYPES constants for type-safe switch statements
- Add isValidBase64() validation for file uploads with size limits
- Add MAX_FILE_SIZE_BYTES (10MB) constant with validation
- Update form-handler.test.ts for FormData instead of JSON
- Update trigger-detector.test.ts for chat URL /chat suffix

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 22:10:42 +01:00
czlonkowski
3cbb02650b 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>
2025-12-01 21:56:19 +01:00
16 changed files with 6669 additions and 4017 deletions

View File

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

View File

@@ -7,6 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [2.28.1] - 2025-12-01
### 🐛 Bug Fixes ### 🐛 Bug Fixes
@@ -519,7 +615,7 @@ Added export/restore functionality for MCP sessions to enable zero-downtime depl
- `restoreSessionState(sessions)` method for session recovery - `restoreSessionState(sessions)` method for session recovery
- Validates session structure using existing `validateInstanceContext()` - Validates session structure using existing `validateInstanceContext()`
- Handles null/invalid sessions gracefully with warnings - Handles null/invalid sessions gracefully with warnings
- Enforces MAX_SESSIONS limit (100 concurrent sessions) - Enforces MAX_SESSIONS limit (default 100, configurable via N8N_MCP_MAX_SESSIONS env var)
- Skips expired sessions during restore - Skips expired sessions during restore
**3. SessionState Type** **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 - **Security-first**: API keys exported as plaintext - downstream MUST encrypt
- **Dormant sessions**: Restored sessions recreate transports on first request - **Dormant sessions**: Restored sessions recreate transports on first request
- **Automatic expiration**: Respects `sessionTimeout` setting (default 30 min) - **Automatic expiration**: Respects `sessionTimeout` setting (default 30 min)
- **MAX_SESSIONS limit**: Caps at 100 concurrent sessions - **MAX_SESSIONS limit**: Caps at 100 concurrent sessions (configurable via N8N_MCP_MAX_SESSIONS env var)
**Important Implementation Notes:** **Important Implementation Notes:**
- Only exports sessions with valid n8nApiUrl and n8nApiKey in context - 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) [![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) [![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) [![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.121.2-orange.svg)](https://github.com/n8n-io/n8n) [![n8n version](https://img.shields.io/badge/n8n-1.122.4-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) [![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) [![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 # Optional: Session configuration
SESSION_TIMEOUT=1800000 # 30 minutes in milliseconds SESSION_TIMEOUT=1800000 # 30 minutes in milliseconds
MAX_SESSIONS=100 N8N_MCP_MAX_SESSIONS=100 # Maximum concurrent sessions (default: 100)
# Optional: Performance # Optional: Performance
NODE_ENV=production NODE_ENV=production

View File

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

10054
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -2,14 +2,15 @@
* Form trigger handler * Form trigger handler
* *
* Handles form-based workflow triggers: * Handles form-based workflow triggers:
* - POST to /form/<workflowId> or /form-test/<workflowId> * - POST to /form/<webhookId> with multipart/form-data
* - Passes form fields as request body * - Supports all n8n form field types: text, textarea, email, number, password, date, dropdown, checkbox, file, hidden
* - Workflow must be active (for production endpoint) * - Workflow must be active (for production endpoint)
*/ */
import { z } from 'zod'; import { z } from 'zod';
import axios, { AxiosRequestConfig } from 'axios'; 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 { import {
TriggerType, TriggerType,
TriggerResponse, TriggerResponse,
@@ -32,6 +33,188 @@ const formInputSchema = z.object({
waitForResponse: z.boolean().optional(), 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 * Form trigger handler
*/ */
@@ -52,20 +235,27 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
): Promise<TriggerResponse> { ): Promise<TriggerResponse> {
const startTime = Date.now(); const startTime = Date.now();
// Extract form field definitions for helpful error messages
const formFieldDefs = extractFormFields(workflow, triggerInfo?.node);
try { try {
// Build form URL // Build form URL
const baseUrl = this.getBaseUrl(); const baseUrl = this.getBaseUrl();
if (!baseUrl) { 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 // Form triggers use /form/<webhookId> endpoint
// The path can be from trigger info or workflow ID const formPath = triggerInfo?.webhookPath || triggerInfo?.node?.parameters?.path || input.workflowId;
const formPath = triggerInfo?.node?.parameters?.path || input.workflowId;
const formUrl = `${baseUrl.replace(/\/+$/, '')}/form/${formPath}`; const formUrl = `${baseUrl.replace(/\/+$/, '')}/form/${formPath}`;
// Merge formData and data (formData takes precedence) // Merge formData and data (formData takes precedence)
const formFields = { const inputFields = {
...input.data, ...input.data,
...input.formData, ...input.formData,
}; };
@@ -77,15 +267,142 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime); 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 // Build request config
const config: AxiosRequestConfig = { const config: AxiosRequestConfig = {
method: 'POST', method: 'POST',
url: formUrl, url: formUrl,
headers: { headers: {
'Content-Type': 'application/json', ...formData.getHeaders(),
...input.headers, ...input.headers,
}, },
data: formFields, data: formData,
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000), timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
validateStatus: (status) => status < 500, validateStatus: (status) => status < 500,
}; };
@@ -93,13 +410,29 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
// Make the request // Make the request
const response = await axios.request(config); const response = await axios.request(config);
return this.normalizeResponse(response.data, input, startTime, { const result = this.normalizeResponse(response.data, input, startTime, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
metadata: { metadata: {
duration: Date.now() - startTime, 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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -110,7 +443,17 @@ export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
return this.errorResponse(input, errorMessage, startTime, { return this.errorResponse(input, errorMessage, startTime, {
executionId, executionId,
code: (error as any)?.code, 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 // Extract webhook path from parameters
const params = node.parameters || {}; const params = node.parameters || {};
const webhookPath = extractWebhookPath(params, node.id); const webhookPath = extractWebhookPath(params, node.id, node.webhookId);
const httpMethod = extractHttpMethod(params); const httpMethod = extractHttpMethod(params);
return { return {
@@ -148,10 +148,12 @@ function detectFormTrigger(node: WorkflowNode): DetectedTrigger | null {
// Extract form fields from parameters // Extract form fields from parameters
const params = node.parameters || {}; const params = node.parameters || {};
const formFields = extractFormFields(params); const formFields = extractFormFields(params);
const webhookPath = extractWebhookPath(params, node.id, node.webhookId);
return { return {
type: 'form', type: 'form',
node, node,
webhookPath,
formFields, formFields,
}; };
} }
@@ -174,7 +176,7 @@ function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null {
// Extract chat configuration // Extract chat configuration
const params = node.parameters || {}; const params = node.parameters || {};
const responseMode = (params.options as any)?.responseMode || 'lastNode'; const responseMode = (params.options as any)?.responseMode || 'lastNode';
const webhookPath = extractWebhookPath(params, node.id); const webhookPath = extractWebhookPath(params, node.id, node.webhookId);
return { return {
type: 'chat', type: 'chat',
@@ -188,8 +190,14 @@ function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null {
/** /**
* Extract webhook path from node parameters * 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 // Check for explicit path parameter
if (typeof params.path === 'string' && params.path) { if (typeof params.path === 'string' && params.path) {
return 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) // Default: use node ID as path (n8n default behavior)
return nodeId; return nodeId;
} }
@@ -262,17 +275,24 @@ export function buildTriggerUrl(
const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); // Remove trailing slashes const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); // Remove trailing slashes
switch (trigger.type) { switch (trigger.type) {
case 'webhook': case 'webhook': {
case 'chat': {
const prefix = mode === 'test' ? 'webhook-test' : 'webhook'; const prefix = mode === 'test' ? 'webhook-test' : 'webhook';
const path = trigger.webhookPath || trigger.node.id; const path = trigger.webhookPath || trigger.node.id;
return `${cleanBaseUrl}/${prefix}/${path}`; 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': { case 'form': {
// Form triggers use /form/<workflowId> endpoint // Form triggers use /form/<webhookId> endpoint
const prefix = mode === 'test' ? 'form-test' : 'form'; 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: default:

View File

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

View File

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

View File

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