Compare commits

...

3 Commits

Author SHA1 Message Date
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
Romuald Członkowski
3188d209b7 fix: AI connection type propagation and get_node improvements (v2.28.1) (#461)
* 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>

* 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>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 18:54:15 +01:00
Romuald Członkowski
33690c5650 feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support (#460)
* feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support

- Rename tool from n8n_trigger_webhook_workflow to n8n_test_workflow
- Add support for webhook, form, and chat triggers (auto-detection)
- Implement modular trigger system with registry pattern
- Add trigger detector for automatic trigger type inference
- Remove execute trigger type (n8n public API limitation)
- Add comprehensive tests for trigger detection and handlers

The tool now auto-detects trigger type from workflow structure and
supports all externally-triggerable workflows via n8n's public API.

Note: Direct workflow execution (Schedule/Manual triggers) requires
n8n's instance-level MCP access, not available via REST API.

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>

* fix: add SSRF protection to webhook handler and update tests

- Add SSRF URL validation to webhook-handler.ts (critical security fix)
  Aligns with existing SSRF protection in form-handler.ts and chat-handler.ts
- Update parameter-validation.test.ts to use new n8n_test_workflow tool name

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>

* feat: n8n_test_workflow unified trigger tool (v2.28.0)

Added new `n8n_test_workflow` tool replacing `n8n_trigger_webhook_workflow`:

Features:
- Auto-detects trigger type (webhook/form/chat) from workflow
- Supports multiple trigger types with type-specific parameters
- SSRF protection for all trigger handlers
- Extensible handler architecture with registry pattern

Changes:
- Fixed Zod schema to remove invalid 'execute' trigger type
- Updated README.md tool documentation
- Added CHANGELOG entry for v2.28.0
- Bumped version to 2.28.0

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>

* test: add comprehensive unit tests for trigger handlers

Added 87 unit tests across 4 test files to improve code coverage:

- base-handler.test.ts (19 tests) - 100% coverage
- webhook-handler.test.ts (22 tests) - 100% coverage
- chat-handler.test.ts (23 tests) - 100% coverage
- form-handler.test.ts (23 tests) - 100% coverage

Tests cover:
- Input validation and parameter handling
- SSRF protection integration
- HTTP method handling and URL building
- Error response formatting
- Execution paths for all trigger types

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>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 15:55:14 +01:00
38 changed files with 5265 additions and 366 deletions

View File

@@ -1,7 +1,7 @@
---
name: n8n-mcp-tester
description: Use this agent when you need to test n8n-mcp functionality after implementing fixes or new features. This agent should be invoked proactively after code changes to validate that the MCP tools work correctly with real n8n workflows. The agent tests the actual MCP server tools, not just unit tests. Examples:\n\n<example>\nContext: Developer has just fixed a validation bug in the n8n-mcp server\nuser: "I've fixed the validation issue for HTTP Request nodes"\nassistant: "I've completed the fix. Now let me build the project and then test it."\n<builds project>\nassistant: "Project built successfully. Please reload the MCP server in Claude Desktop to apply the changes."\nuser: "Reloaded"\nassistant: "Great! Now let me invoke the n8n-mcp-tester agent to verify the fix works correctly."\n<commentary>\nSince a fix has been implemented and the MCP server reloaded, use the n8n-mcp-tester agent to validate the fix works in real-world usage.\n</commentary>\n</example>\n\n<example>\nContext: Developer has added a new MCP tool for workflow validation\nuser: "I've implemented the new workflow validation tool"\nassistant: "Implementation complete. Let me build and prepare for testing."\n<builds project>\nassistant: "Build successful. Please reload the MCP server to load the new tool."\nuser: "Done"\nassistant: "Perfect! I'll now use the n8n-mcp-tester agent to test the new workflow validation tool."\n<commentary>\nAfter implementing new MCP functionality and reloading the server, invoke n8n-mcp-tester to verify it works correctly.\n</commentary>\n</example>
tools: Glob, Grep, LS, Read, WebFetch, TodoWrite, WebSearch, mcp__puppeteer__puppeteer_navigate, mcp__puppeteer__puppeteer_screenshot, mcp__puppeteer__puppeteer_click, mcp__puppeteer__puppeteer_fill, mcp__puppeteer__puppeteer_select, mcp__puppeteer__puppeteer_hover, mcp__puppeteer__puppeteer_evaluate, ListMcpResourcesTool, ReadMcpResourceTool, mcp__supabase__list_organizations, mcp__supabase__get_organization, mcp__supabase__list_projects, mcp__supabase__get_project, mcp__supabase__get_cost, mcp__supabase__confirm_cost, mcp__supabase__create_project, mcp__supabase__pause_project, mcp__supabase__restore_project, mcp__supabase__create_branch, mcp__supabase__list_branches, mcp__supabase__delete_branch, mcp__supabase__merge_branch, mcp__supabase__reset_branch, mcp__supabase__rebase_branch, mcp__supabase__list_tables, mcp__supabase__list_extensions, mcp__supabase__list_migrations, mcp__supabase__apply_migration, mcp__supabase__execute_sql, mcp__supabase__get_logs, mcp__supabase__get_advisors, mcp__supabase__get_project_url, mcp__supabase__get_anon_key, mcp__supabase__generate_typescript_types, mcp__supabase__search_docs, mcp__supabase__list_edge_functions, mcp__supabase__deploy_edge_function, mcp__n8n-mcp__tools_documentation, mcp__n8n-mcp__list_nodes, mcp__n8n-mcp__get_node_info, mcp__n8n-mcp__search_nodes, mcp__n8n-mcp__list_ai_tools, mcp__n8n-mcp__get_node_documentation, mcp__n8n-mcp__get_database_statistics, mcp__n8n-mcp__get_node_essentials, mcp__n8n-mcp__search_node_properties, mcp__n8n-mcp__get_node_for_task, mcp__n8n-mcp__list_tasks, mcp__n8n-mcp__validate_node_operation, mcp__n8n-mcp__validate_node_minimal, mcp__n8n-mcp__get_property_dependencies, mcp__n8n-mcp__get_node_as_tool_info, mcp__n8n-mcp__list_node_templates, mcp__n8n-mcp__get_template, mcp__n8n-mcp__search_templates, mcp__n8n-mcp__get_templates_for_task, mcp__n8n-mcp__validate_workflow, mcp__n8n-mcp__validate_workflow_connections, mcp__n8n-mcp__validate_workflow_expressions, mcp__n8n-mcp__n8n_create_workflow, mcp__n8n-mcp__n8n_get_workflow, mcp__n8n-mcp__n8n_get_workflow_details, mcp__n8n-mcp__n8n_get_workflow_structure, mcp__n8n-mcp__n8n_get_workflow_minimal, mcp__n8n-mcp__n8n_update_full_workflow, mcp__n8n-mcp__n8n_update_partial_workflow, mcp__n8n-mcp__n8n_delete_workflow, mcp__n8n-mcp__n8n_list_workflows, mcp__n8n-mcp__n8n_validate_workflow, mcp__n8n-mcp__n8n_trigger_webhook_workflow, mcp__n8n-mcp__n8n_get_execution, mcp__n8n-mcp__n8n_list_executions, mcp__n8n-mcp__n8n_delete_execution, mcp__n8n-mcp__n8n_health_check, mcp__n8n-mcp__n8n_list_available_tools, mcp__n8n-mcp__n8n_diagnostic
tools: Glob, Grep, Read, WebFetch, TodoWrite, WebSearch, mcp__supabase__create_branch, mcp__supabase__list_branches, mcp__supabase__delete_branch, mcp__supabase__merge_branch, mcp__supabase__reset_branch, mcp__supabase__rebase_branch, mcp__supabase__list_tables, mcp__supabase__list_extensions, mcp__supabase__list_migrations, mcp__supabase__apply_migration, mcp__supabase__execute_sql, mcp__supabase__get_logs, mcp__supabase__get_advisors, mcp__supabase__get_project_url, mcp__supabase__generate_typescript_types, mcp__supabase__search_docs, mcp__supabase__list_edge_functions, mcp__supabase__deploy_edge_function, mcp__n8n-mcp__tools_documentation, mcp__n8n-mcp__search_nodes, mcp__n8n-mcp__get_template, mcp__n8n-mcp__search_templates, mcp__n8n-mcp__validate_workflow, mcp__n8n-mcp__n8n_create_workflow, mcp__n8n-mcp__n8n_get_workflow, mcp__n8n-mcp__n8n_update_full_workflow, mcp__n8n-mcp__n8n_update_partial_workflow, mcp__n8n-mcp__n8n_delete_workflow, mcp__n8n-mcp__n8n_list_workflows, mcp__n8n-mcp__n8n_validate_workflow, mcp__n8n-mcp__n8n_trigger_webhook_workflow, mcp__n8n-mcp__n8n_health_check, mcp__brightdata-mcp__search_engine, mcp__brightdata-mcp__scrape_as_markdown, mcp__brightdata-mcp__search_engine_batch, mcp__brightdata-mcp__scrape_batch, mcp__supabase__get_publishable_keys, mcp__supabase__get_edge_function, mcp__n8n-mcp__get_node, mcp__n8n-mcp__validate_node, mcp__n8n-mcp__n8n_autofix_workflow, mcp__n8n-mcp__n8n_executions, mcp__n8n-mcp__n8n_workflow_versions, mcp__n8n-mcp__n8n_deploy_template, mcp__ide__getDiagnostics, mcp__ide__executeCode
model: sonnet
---

View File

@@ -7,6 +7,201 @@ 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
**Issue #458: AI Connection Type Propagation**
Fixed `addConnection` operation in workflow diff engine defaulting `targetInput` to "main" instead of preserving the source output type. This caused AI tool connections to be created with incorrect type.
- **Root Cause**: `targetInput` defaulted to `'main'` regardless of `sourceOutput` type
- **Fix**: Changed default to `sourceOutput` to preserve connection type (ai_tool, ai_memory, ai_languageModel)
- **Files**: `src/services/workflow-diff-engine.ts:760`
**AI Agent Validation False Positive**
Fixed false positive "AI Agent has no tools connected" warning when tools were properly connected.
- **Root Cause**: Validation checked connections FROM agent instead of TO agent
- **Fix**: Search all connections where target node is the agent
- **Files**: `src/services/workflow-validator.ts:1148-1163`
### ✨ Enhancements
**get_node: expectedFormat for resourceLocator Properties**
Added `expectedFormat` field to resourceLocator properties in `get_node` output. This helps AI models understand the correct format for these complex property types.
```json
{
"name": "model",
"type": "resourceLocator",
"expectedFormat": {
"structure": { "mode": "string", "value": "string" },
"modes": ["list", "id"],
"example": { "mode": "id", "value": "gpt-4o-mini" }
}
}
```
**get_node: versionNotice Field**
Added `versionNotice` field to make typeVersion more prominent in get_node output, reducing the chance of AI models using outdated versions.
```json
{
"version": "1.3",
"versionNotice": "⚠️ Use typeVersion: 1.3 when creating this node"
}
```
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
## [2.28.0] - 2025-12-01
### ✨ Features
**n8n_test_workflow: Unified Workflow Trigger Tool**
Replaced `n8n_trigger_webhook_workflow` with a new unified `n8n_test_workflow` tool that supports multiple trigger types with auto-detection.
#### Key Features
1. **Auto-Detection of Trigger Type**
- Automatically analyzes workflow to detect trigger type (webhook, form, or chat)
- No need to specify triggerType unless you want to override detection
2. **Multi-Trigger Support**
- **Webhook**: HTTP-based triggers (GET/POST/PUT/DELETE) with custom headers and data
- **Form**: Form submission triggers with form field data
- **Chat**: AI chat triggers with message and session continuity
3. **SSRF Protection**
- All trigger handlers include SSRF URL validation
- Blocks requests to private networks, cloud metadata endpoints
- Configurable security modes (strict/moderate/permissive)
4. **Extensible Handler Architecture**
- Plugin-based trigger handler system
- Registry pattern for easy extension
- Clean separation of concerns
#### Usage
```javascript
// Auto-detect trigger type (recommended)
n8n_test_workflow({workflowId: "123"})
// Webhook with data
n8n_test_workflow({
workflowId: "123",
triggerType: "webhook",
httpMethod: "POST",
data: {name: "John", email: "john@example.com"}
})
// Chat trigger
n8n_test_workflow({
workflowId: "123",
triggerType: "chat",
message: "Hello AI assistant",
sessionId: "conversation-123"
})
// Form submission
n8n_test_workflow({
workflowId: "123",
triggerType: "form",
data: {email: "test@example.com", name: "Test User"}
})
```
#### Breaking Changes
- **Removed**: `n8n_trigger_webhook_workflow` tool
- **Replaced by**: `n8n_test_workflow` with enhanced capabilities
- **Migration**: Change tool name and add `workflowId` parameter (previously `webhookUrl`)
#### Technical Details
**New Files:**
- `src/triggers/` - Complete trigger system module
- `types.ts` - Type definitions for all trigger types
- `trigger-detector.ts` - Auto-detection logic
- `trigger-registry.ts` - Handler registration
- `handlers/` - Individual handler implementations
**Modified Files:**
- `src/mcp/handlers-n8n-manager.ts` - New `handleTestWorkflow` function
- `src/mcp/tools-n8n-manager.ts` - Updated tool definition
- `src/mcp/tool-docs/workflow_management/` - New documentation
**Test Coverage:**
- 32 unit tests for trigger detection and registry
- 30 unit tests for SSRF protection
- All parameter validation tests updated
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
## [2.27.2] - 2025-11-29
### ✨ Enhanced Features

View File

@@ -598,7 +598,7 @@ ALWAYS explicitly configure ALL parameters that control node behavior.
- `n8n_create_workflow(workflow)` - Deploy
- `n8n_validate_workflow({id})` - Post-deployment check
- `n8n_update_partial_workflow({id, operations: [...]})` - Batch updates
- `n8n_trigger_webhook_workflow()` - Test webhooks
- `n8n_test_workflow({workflowId})` - Test workflow execution
## Critical Warnings
@@ -977,7 +977,10 @@ These tools require `N8N_API_URL` and `N8N_API_KEY` in your configuration.
- **`n8n_deploy_template`** - Deploy templates from n8n.io directly to your instance with auto-fix
#### Execution Management
- **`n8n_trigger_webhook_workflow`** - Trigger workflows via webhook URL
- **`n8n_test_workflow`** - Test/trigger workflow execution:
- Auto-detects trigger type (webhook, form, chat) from workflow
- Supports custom data, headers, and HTTP methods for webhooks
- Chat triggers support message and sessionId for conversations
- **`n8n_executions`** - Unified execution management (v2.26.0):
- `action: 'list'` - List executions with status filtering
- `action: 'get'` - Get execution details by ID

470
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "n8n-mcp",
"version": "2.27.0",
"version": "2.28.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "n8n-mcp",
"version": "2.27.0",
"version": "2.28.2",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "1.20.1",
@@ -15,6 +15,7 @@
"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.121.2",
"n8n-core": "^1.120.1",
@@ -6654,6 +6655,20 @@
}
}
},
"node_modules/@getzep/zep-cloud/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@getzep/zep-js": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@getzep/zep-js/-/zep-js-0.9.0.tgz",
@@ -7060,23 +7075,6 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@ibm-cloud/watsonx-ai/node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@ibm-cloud/watsonx-ai/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@@ -9088,6 +9086,20 @@
"reflect-metadata": "0.2.2"
}
},
"node_modules/@n8n/ai-workflow-builder/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@n8n/ai-workflow-builder/node_modules/n8n-workflow": {
"version": "1.118.1",
"resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.118.1.tgz",
@@ -9141,6 +9153,20 @@
"zod-class": "0.0.16"
}
},
"node_modules/@n8n/api-types/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@n8n/api-types/node_modules/n8n-workflow": {
"version": "1.118.1",
"resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.118.1.tgz",
@@ -9292,22 +9318,6 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/@n8n/client-oauth2/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@n8n/config": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@n8n/config/-/config-1.64.0.tgz",
@@ -9665,6 +9675,41 @@
"@supabase/storage-js": "2.7.1"
}
},
"node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@n8n/n8n-nodes-langchain/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -10297,43 +10342,6 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/@n8n/task-runner/node_modules/axios/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@n8n/task-runner/node_modules/axios/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@n8n/task-runner/node_modules/axios/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@n8n/task-runner/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -10449,6 +10457,41 @@
"n8n-generate-translations": "bin/generate-translations"
}
},
"node_modules/@n8n/task-runner/node_modules/n8n-core/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@n8n/task-runner/node_modules/n8n-core/node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@n8n/task-runner/node_modules/n8n-core/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@n8n/task-runner/node_modules/n8n-workflow": {
"version": "1.118.1",
"resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.118.1.tgz",
@@ -10474,6 +10517,41 @@
"zod": "3.25.67"
}
},
"node_modules/@n8n/task-runner/node_modules/n8n-workflow/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@n8n/task-runner/node_modules/n8n-workflow/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@n8n/task-runner/node_modules/n8n-workflow/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@n8n/task-runner/node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
@@ -14545,22 +14623,6 @@
"form-data": "^4.0.4"
}
},
"node_modules/@types/node-fetch/node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@types/pg": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",
@@ -15803,22 +15865,6 @@
"axios": "0.x || 1.x"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -19301,13 +19347,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -20579,23 +20627,6 @@
"undici-types": "~5.26.4"
}
},
"node_modules/ibm-cloud-sdk-core/node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/ibm-cloud-sdk-core/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@@ -24863,6 +24894,41 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/n8n-core/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/n8n-core/node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n-core/node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n-core/node_modules/htmlparser2": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
@@ -25351,6 +25417,20 @@
"zod": "3.25.67"
}
},
"node_modules/n8n-workflow/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/n8n-workflow/node_modules/zod": {
"version": "3.25.67",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
@@ -25515,6 +25595,41 @@
"zod-to-json-schema": "3.23.3"
}
},
"node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n/node_modules/@n8n/n8n-nodes-langchain/node_modules/openai": {
"version": "5.12.2",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz",
@@ -26134,43 +26249,6 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/n8n/node_modules/axios/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/n8n/node_modules/axios/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n/node_modules/axios/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n/node_modules/cheerio-select": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-1.6.0.tgz",
@@ -26493,6 +26571,41 @@
"n8n-generate-translations": "bin/generate-translations"
}
},
"node_modules/n8n/node_modules/n8n-core/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/n8n/node_modules/n8n-core/node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n/node_modules/n8n-core/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n/node_modules/n8n-nodes-base": {
"version": "1.119.1",
"resolved": "https://registry.npmjs.org/n8n-nodes-base/-/n8n-nodes-base-1.119.1.tgz",
@@ -26683,6 +26796,41 @@
"zod": "3.25.67"
}
},
"node_modules/n8n/node_modules/n8n-workflow/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/n8n/node_modules/n8n-workflow/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n/node_modules/n8n-workflow/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/n8n/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.27.2",
"version": "2.28.2",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -146,6 +146,7 @@
"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.121.2",
"n8n-core": "^1.120.1",

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp-runtime",
"version": "2.27.2",
"version": "2.28.0",
"description": "n8n MCP Server Runtime Dependencies Only",
"private": true,
"dependencies": {

View File

@@ -10,6 +10,7 @@ import {
ExecutionFilterOptions,
ExecutionMode
} from '../types/n8n-api';
import type { TriggerType, TestWorkflowInput } from '../triggers/types';
import {
validateWorkflowStructure,
hasWebhookTrigger,
@@ -429,11 +430,17 @@ const autofixWorkflowSchema = z.object({
maxFixes: z.number().optional().default(50)
});
const triggerWebhookSchema = z.object({
webhookUrl: z.string().url(),
// Schema for n8n_test_workflow tool
const testWorkflowSchema = z.object({
workflowId: z.string(),
triggerType: z.enum(['webhook', 'form', 'chat']).optional(),
httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(),
webhookPath: z.string().optional(),
message: z.string().optional(),
sessionId: z.string().optional(),
data: z.record(z.unknown()).optional(),
headers: z.record(z.string()).optional(),
timeout: z.number().optional(),
waitForResponse: z.boolean().optional(),
});
@@ -1235,74 +1242,160 @@ export async function handleAutofixWorkflow(
// Execution Management Handlers
export async function handleTriggerWebhookWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
/**
* Handler for n8n_test_workflow tool
* Triggers workflow execution via auto-detected or specified trigger type
*/
export async function handleTestWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const input = triggerWebhookSchema.parse(args);
const input = testWorkflowSchema.parse(args);
const webhookRequest: WebhookRequest = {
webhookUrl: input.webhookUrl,
httpMethod: input.httpMethod || 'POST',
// Import trigger system (lazy to avoid circular deps)
const {
detectTriggerFromWorkflow,
ensureRegistryInitialized,
TriggerRegistry,
} = await import('../triggers');
// Ensure registry is initialized
await ensureRegistryInitialized();
// Fetch the workflow to analyze its trigger
const workflow = await client.getWorkflow(input.workflowId);
// Determine trigger type
let triggerType: TriggerType | undefined = input.triggerType as TriggerType | undefined;
let triggerInfo;
// Auto-detect from workflow
const detection = detectTriggerFromWorkflow(workflow);
if (!triggerType) {
if (detection.detected && detection.trigger) {
triggerType = detection.trigger.type;
triggerInfo = detection.trigger;
} else {
// No externally-triggerable trigger found
return {
success: false,
error: 'Workflow cannot be triggered externally',
details: {
workflowId: input.workflowId,
reason: detection.reason,
hint: 'Only workflows with webhook, form, or chat triggers can be executed via the API. Add one of these trigger nodes to your workflow.',
},
};
}
} else {
// User specified a trigger type, verify it matches workflow
if (detection.detected && detection.trigger?.type === triggerType) {
triggerInfo = detection.trigger;
} else if (!detection.detected || detection.trigger?.type !== triggerType) {
return {
success: false,
error: `Workflow does not have a ${triggerType} trigger`,
details: {
workflowId: input.workflowId,
requestedTrigger: triggerType,
detectedTrigger: detection.trigger?.type || 'none',
hint: detection.detected
? `Workflow has a ${detection.trigger?.type} trigger. Either use that type or omit triggerType for auto-detection.`
: 'Workflow has no externally-triggerable triggers (webhook, form, or chat).',
},
};
}
}
// Get handler for trigger type
const handler = TriggerRegistry.getHandler(triggerType, client, context);
if (!handler) {
return {
success: false,
error: `No handler registered for trigger type: ${triggerType}`,
details: {
supportedTypes: TriggerRegistry.getRegisteredTypes(),
},
};
}
// Check if workflow is active (if required by handler)
if (handler.capabilities.requiresActiveWorkflow && !workflow.active) {
return {
success: false,
error: 'Workflow must be active to trigger via this method',
details: {
workflowId: input.workflowId,
triggerType,
hint: 'Activate the workflow in n8n using n8n_update_partial_workflow with [{type: "activateWorkflow"}]',
},
};
}
// Validate chat trigger has message
if (triggerType === 'chat' && !input.message) {
return {
success: false,
error: 'Chat trigger requires a message parameter',
details: {
hint: 'Provide message="your message" for chat triggers',
},
};
}
// Build trigger-specific input
const triggerInput = {
workflowId: input.workflowId,
triggerType,
httpMethod: input.httpMethod,
webhookPath: input.webhookPath,
message: input.message || '',
sessionId: input.sessionId,
data: input.data,
formData: input.data, // For form triggers
headers: input.headers,
waitForResponse: input.waitForResponse ?? true
timeout: input.timeout,
waitForResponse: input.waitForResponse,
};
const response = await client.triggerWebhook(webhookRequest);
// Execute the trigger
const response = await handler.execute(triggerInput as any, workflow, triggerInfo);
return {
success: true,
data: response,
message: 'Webhook triggered successfully'
success: response.success,
data: response.data,
message: response.success
? `Workflow triggered successfully via ${triggerType}`
: response.error,
executionId: response.executionId,
workflowId: input.workflowId,
details: {
triggerType,
metadata: response.metadata,
...(response.details || {}),
},
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Invalid input',
details: { errors: error.errors }
details: { errors: error.errors },
};
}
if (error instanceof N8nApiError) {
// Try to extract execution context from error response
const errorData = error.details as any;
const executionId = errorData?.executionId || errorData?.id || errorData?.execution?.id;
const workflowId = errorData?.workflowId || errorData?.workflow?.id;
// If we have execution ID, provide specific guidance with n8n_get_execution
if (executionId) {
return {
success: false,
error: formatExecutionError(executionId, workflowId),
code: error.code,
executionId,
workflowId: workflowId || undefined
};
}
// No execution ID available - workflow likely didn't start
// Provide guidance to check recent executions
if (error.code === 'SERVER_ERROR' || error.statusCode && error.statusCode >= 500) {
return {
success: false,
error: formatNoExecutionError(),
code: error.code
};
}
// For other errors (auth, validation, etc), use standard message
return {
success: false,
error: getUserFriendlyErrorMessage(error),
code: error.code,
details: error.details as Record<string, unknown> | undefined
details: error.details as Record<string, unknown> | undefined,
};
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
@@ -2449,3 +2542,84 @@ export async function handleDeployTemplate(
};
}
}
/**
* Backward-compatible webhook trigger handler
*
* @deprecated Use handleTestWorkflow instead. This function is kept for
* backward compatibility with existing integration tests.
*/
export async function handleTriggerWebhookWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
const triggerWebhookSchema = z.object({
webhookUrl: z.string().url(),
httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(),
data: z.record(z.unknown()).optional(),
headers: z.record(z.string()).optional(),
waitForResponse: z.boolean().optional(),
});
try {
const client = ensureApiConfigured(context);
const input = triggerWebhookSchema.parse(args);
const webhookRequest: WebhookRequest = {
webhookUrl: input.webhookUrl,
httpMethod: input.httpMethod || 'POST',
data: input.data,
headers: input.headers,
waitForResponse: input.waitForResponse ?? true
};
const response = await client.triggerWebhook(webhookRequest);
return {
success: true,
data: response,
message: 'Webhook triggered successfully'
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Invalid input',
details: { errors: error.errors }
};
}
if (error instanceof N8nApiError) {
const errorData = error.details as any;
const executionId = errorData?.executionId || errorData?.id || errorData?.execution?.id;
const workflowId = errorData?.workflowId || errorData?.workflow?.id;
if (executionId) {
return {
success: false,
error: formatExecutionError(executionId, workflowId),
code: error.code,
executionId,
workflowId: workflowId || undefined
};
}
if (error.code === 'SERVER_ERROR' || error.statusCode && error.statusCode >= 500) {
return {
success: false,
error: formatNoExecutionError(),
code: error.code
};
}
return {
success: false,
error: getUserFriendlyErrorMessage(error),
code: error.code,
details: error.details as Record<string, unknown> | undefined
};
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}

View File

@@ -1176,9 +1176,9 @@ export class N8NDocumentationMCPServer {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
return n8nHandlers.handleAutofixWorkflow(args, this.repository, this.instanceContext);
case 'n8n_trigger_webhook_workflow':
this.validateToolParams(name, args, ['webhookUrl']);
return n8nHandlers.handleTriggerWebhookWorkflow(args, this.instanceContext);
case 'n8n_test_workflow':
this.validateToolParams(name, args, ['workflowId']);
return n8nHandlers.handleTestWorkflow(args, this.instanceContext);
case 'n8n_executions': {
this.validateToolParams(name, args, ['action']);
const execAction = args.action;
@@ -2203,14 +2203,19 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
// Get operations (already parsed by repository)
const operations = node.operations || [];
// Get the latest version - this is important for AI to use correct typeVersion
const latestVersion = node.version ?? '1';
const result = {
nodeType: node.nodeType,
workflowNodeType: getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType),
displayName: node.displayName,
description: node.description,
category: node.category,
version: node.version ?? '1',
version: latestVersion,
isVersioned: node.isVersioned ?? false,
// Prominent warning to use the correct typeVersion
versionNotice: `⚠️ Use typeVersion: ${latestVersion} when creating this node`,
requiredProperties: essentials.required,
commonProperties: essentials.common,
operations: operations.map((op: any) => ({

View File

@@ -19,7 +19,7 @@ import {
n8nListWorkflowsDoc,
n8nValidateWorkflowDoc,
n8nAutofixWorkflowDoc,
n8nTriggerWebhookWorkflowDoc,
n8nTestWorkflowDoc,
n8nExecutionsDoc,
n8nWorkflowVersionsDoc,
n8nDeployTemplateDoc
@@ -57,7 +57,7 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
n8n_list_workflows: n8nListWorkflowsDoc,
n8n_validate_workflow: n8nValidateWorkflowDoc,
n8n_autofix_workflow: n8nAutofixWorkflowDoc,
n8n_trigger_webhook_workflow: n8nTriggerWebhookWorkflowDoc,
n8n_test_workflow: n8nTestWorkflowDoc,
n8n_executions: n8nExecutionsDoc,
n8n_workflow_versions: n8nWorkflowVersionsDoc,
n8n_deploy_template: n8nDeployTemplateDoc

View File

@@ -6,7 +6,7 @@ export { n8nDeleteWorkflowDoc } from './n8n-delete-workflow';
export { n8nListWorkflowsDoc } from './n8n-list-workflows';
export { n8nValidateWorkflowDoc } from './n8n-validate-workflow';
export { n8nAutofixWorkflowDoc } from './n8n-autofix-workflow';
export { n8nTriggerWebhookWorkflowDoc } from './n8n-trigger-webhook-workflow';
export { n8nTestWorkflowDoc } from './n8n-test-workflow';
export { n8nExecutionsDoc } from './n8n-executions';
export { n8nWorkflowVersionsDoc } from './n8n-workflow-versions';
export { n8nDeployTemplateDoc } from './n8n-deploy-template';

View File

@@ -84,7 +84,7 @@ n8n_create_workflow({
'Validate with validate_workflow first',
'Use unique node IDs',
'Position nodes for readability',
'Test with n8n_trigger_webhook_workflow'
'Test with n8n_test_workflow'
],
pitfalls: [
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - tool unavailable without n8n API access',
@@ -95,6 +95,6 @@ n8n_create_workflow({
'**Auto-sanitization runs on creation**: All nodes sanitized before workflow created (operator structures fixed, missing metadata added)',
'**Auto-sanitization cannot prevent all failures**: Broken connections or invalid node configurations may still cause creation to fail'
],
relatedTools: ['validate_workflow', 'n8n_update_partial_workflow', 'n8n_trigger_webhook_workflow']
relatedTools: ['validate_workflow', 'n8n_update_partial_workflow', 'n8n_test_workflow']
}
};

View File

@@ -77,6 +77,6 @@ export const n8nExecutionsDoc: ToolDocumentation = {
'Execution must exist or returns 404',
'Delete is permanent - cannot undo'
],
relatedTools: ['n8n_get_workflow', 'n8n_trigger_webhook_workflow', 'n8n_validate_workflow']
relatedTools: ['n8n_get_workflow', 'n8n_test_workflow', 'n8n_validate_workflow']
}
};

View File

@@ -0,0 +1,138 @@
import { ToolDocumentation } from '../types';
export const n8nTestWorkflowDoc: ToolDocumentation = {
name: 'n8n_test_workflow',
category: 'workflow_management',
essentials: {
description: 'Test/trigger workflow execution. Auto-detects trigger type (webhook/form/chat). Only workflows with these triggers can be executed externally.',
keyParameters: ['workflowId', 'triggerType', 'data', 'message'],
example: 'n8n_test_workflow({workflowId: "123"}) - auto-detect trigger',
performance: 'Immediate trigger, response time depends on workflow complexity',
tips: [
'Auto-detects trigger type from workflow if not specified',
'Workflow must have a webhook, form, or chat trigger to be executable',
'For chat triggers, message is required',
'All trigger types require the workflow to be ACTIVE'
]
},
full: {
description: `Test and trigger n8n workflows through HTTP-based methods. This unified tool supports multiple trigger types:
**Trigger Types:**
- **webhook**: HTTP-based triggers (GET/POST/PUT/DELETE)
- **form**: Form submission triggers
- **chat**: AI chat triggers with conversation support
**Important:** n8n's public API does not support direct workflow execution. Only workflows with webhook, form, or chat triggers can be executed externally. Workflows with schedule, manual, or other trigger types cannot be triggered via this API.
The tool auto-detects the appropriate trigger type by analyzing the workflow's trigger node. You can override this with the triggerType parameter.`,
parameters: {
workflowId: {
type: 'string',
required: true,
description: 'Workflow ID to execute'
},
triggerType: {
type: 'string',
required: false,
enum: ['webhook', 'form', 'chat'],
description: 'Trigger type. Auto-detected if not specified. Workflow must have matching trigger node.'
},
httpMethod: {
type: 'string',
required: false,
enum: ['GET', 'POST', 'PUT', 'DELETE'],
description: 'For webhook: HTTP method (default: from workflow config or POST)'
},
webhookPath: {
type: 'string',
required: false,
description: 'For webhook: override the webhook path'
},
message: {
type: 'string',
required: false,
description: 'For chat: message to send (required for chat triggers)'
},
sessionId: {
type: 'string',
required: false,
description: 'For chat: session ID for conversation continuity'
},
data: {
type: 'object',
required: false,
description: 'Input data/payload for webhook or form fields'
},
headers: {
type: 'object',
required: false,
description: 'Custom HTTP headers'
},
timeout: {
type: 'number',
required: false,
description: 'Timeout in ms (default: 120000)'
},
waitForResponse: {
type: 'boolean',
required: false,
description: 'Wait for workflow completion (default: true)'
}
},
returns: `Execution response including:
- success: boolean
- data: workflow output data
- executionId: for tracking/debugging
- triggerType: detected or specified trigger type
- metadata: timing and request details`,
examples: [
'n8n_test_workflow({workflowId: "123"}) - Auto-detect and trigger',
'n8n_test_workflow({workflowId: "123", triggerType: "webhook", data: {name: "John"}}) - Webhook with data',
'n8n_test_workflow({workflowId: "123", triggerType: "chat", message: "Hello AI"}) - Chat trigger',
'n8n_test_workflow({workflowId: "123", triggerType: "form", data: {email: "test@example.com"}}) - Form submission'
],
useCases: [
'Test workflows during development',
'Trigger AI chat workflows with messages',
'Submit form data to form-triggered workflows',
'Integrate n8n workflows with external systems via webhooks'
],
performance: `Performance varies based on workflow complexity and waitForResponse setting:
- Webhook: Immediate trigger, depends on workflow
- Form: Immediate trigger, depends on workflow
- Chat: May have additional AI processing time`,
errorHandling: `**Error Response with Execution Guidance**
When execution fails, the response includes guidance for debugging:
**With Execution ID** (workflow started but failed):
- Use n8n_executions({action: 'get', id: executionId, mode: 'preview'}) to investigate
**Without Execution ID** (workflow didn't start):
- Use n8n_executions({action: 'list', workflowId: 'wf_id'}) to find recent executions
**Common Errors:**
- "Workflow not found" - Check workflow ID exists
- "Workflow not active" - Activate workflow (required for all trigger types)
- "Workflow cannot be triggered externally" - Workflow has no webhook/form/chat trigger
- "Chat message required" - Provide message parameter for chat triggers
- "SSRF protection" - URL validation failed`,
bestPractices: [
'Let auto-detection choose the trigger type when possible',
'Ensure workflow has a webhook, form, or chat trigger before testing',
'For chat workflows, provide sessionId for multi-turn conversations',
'Use mode="preview" with n8n_executions for efficient debugging',
'Test with small data payloads first',
'Activate workflows before testing (use n8n_update_partial_workflow with activateWorkflow)'
],
pitfalls: [
'All trigger types require the workflow to be ACTIVE',
'Workflows without webhook/form/chat triggers cannot be executed externally',
'Chat trigger requires message parameter',
'Form data must match expected form fields',
'Webhook method must match node configuration'
],
relatedTools: ['n8n_executions', 'n8n_get_workflow', 'n8n_create_workflow', 'n8n_validate_workflow']
}
};

View File

@@ -1,118 +0,0 @@
import { ToolDocumentation } from '../types';
export const n8nTriggerWebhookWorkflowDoc: ToolDocumentation = {
name: 'n8n_trigger_webhook_workflow',
category: 'workflow_management',
essentials: {
description: 'Trigger workflow via webhook. Must be ACTIVE with Webhook node. Method must match config.',
keyParameters: ['webhookUrl', 'httpMethod', 'data'],
example: 'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/abc-def-ghi"})',
performance: 'Immediate trigger, response time depends on workflow complexity',
tips: [
'Workflow MUST be active and contain a Webhook node for triggering',
'HTTP method must match webhook node configuration (often GET)',
'Use waitForResponse:false for async execution without waiting'
]
},
full: {
description: `Triggers a workflow execution via its webhook URL. This is the primary method for external systems to start n8n workflows. The target workflow must be active and contain a properly configured Webhook node as the trigger. The HTTP method used must match the webhook configuration.`,
parameters: {
webhookUrl: {
type: 'string',
required: true,
description: 'Full webhook URL from n8n workflow (e.g., https://n8n.example.com/webhook/abc-def-ghi)'
},
httpMethod: {
type: 'string',
required: false,
enum: ['GET', 'POST', 'PUT', 'DELETE'],
description: 'HTTP method (must match webhook configuration, often GET). Defaults to GET if not specified'
},
data: {
type: 'object',
required: false,
description: 'Data to send with the webhook request. For GET requests, becomes query parameters'
},
headers: {
type: 'object',
required: false,
description: 'Additional HTTP headers to include in the request'
},
waitForResponse: {
type: 'boolean',
required: false,
description: 'Wait for workflow completion and return results (default: true). Set to false for fire-and-forget'
}
},
returns: `Webhook response data if waitForResponse is true, or immediate acknowledgment if false. Response format depends on webhook node configuration.`,
examples: [
'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/order-process"}) - Trigger with GET',
'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/data-import", httpMethod: "POST", data: {name: "John", email: "john@example.com"}}) - POST with data',
'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/async-job", waitForResponse: false}) - Fire and forget',
'n8n_trigger_webhook_workflow({webhookUrl: "https://n8n.example.com/webhook/api", headers: {"API-Key": "secret"}}) - With auth headers'
],
useCases: [
'Trigger data processing workflows from external applications',
'Start scheduled jobs manually via webhook',
'Integrate n8n workflows with third-party services',
'Create REST API endpoints using n8n workflows',
'Implement event-driven architectures with n8n'
],
performance: `Performance varies based on workflow complexity and waitForResponse setting. Synchronous calls (waitForResponse: true) block until workflow completes. For long-running workflows, use async mode (waitForResponse: false) and monitor execution separately.`,
errorHandling: `**Enhanced Error Messages with Execution Guidance**
When a webhook trigger fails, the error response now includes specific guidance to help debug the issue:
**Error with Execution ID** (workflow started but failed):
- Format: "Workflow {workflowId} execution {executionId} failed. Use n8n_executions({action: 'get', id: '{executionId}', mode: 'preview'}) to investigate the error."
- Response includes: executionId and workflowId fields for direct access
- Recommended action: Use n8n_executions with action='get' and mode='preview' for fast, efficient error inspection
**Error without Execution ID** (workflow didn't start):
- Format: "Workflow failed to execute. Use n8n_executions({action: 'list'}) to find recent executions, then n8n_executions({action: 'get', mode: 'preview'}) to investigate."
- Recommended action: Check recent executions with n8n_executions({action: 'list'})
**Why mode='preview'?**
- Fast: <50ms response time
- Efficient: ~500 tokens (vs 50K+ for full mode)
- Safe: No timeout or token limit risks
- Informative: Shows structure, counts, and error details
- Provides recommendations for fetching more data if needed
**Example Error Responses**:
\`\`\`json
{
"success": false,
"error": "Workflow wf_123 execution exec_456 failed. Use n8n_get_execution({id: 'exec_456', mode: 'preview'}) to investigate the error.",
"executionId": "exec_456",
"workflowId": "wf_123",
"code": "SERVER_ERROR"
}
\`\`\`
**Investigation Workflow**:
1. Trigger returns error with execution ID
2. Call n8n_executions({action: 'get', id: executionId, mode: 'preview'}) to see structure and error
3. Based on preview recommendation, fetch more data if needed
4. Fix issues in workflow and retry`,
bestPractices: [
'Always verify workflow is active before attempting webhook triggers',
'Match HTTP method exactly with webhook node configuration',
'Use async mode (waitForResponse: false) for long-running workflows',
'Include authentication headers when webhook requires them',
'Test webhook URL manually first to ensure it works',
'When errors occur, use n8n_executions with action="get" and mode="preview" first for efficient debugging',
'Store execution IDs from error responses for later investigation'
],
pitfalls: [
'Workflow must be ACTIVE - inactive workflows cannot be triggered',
'HTTP method mismatch returns 404 even if URL is correct',
'Webhook node must be the trigger node in the workflow',
'Timeout errors occur with long workflows in sync mode',
'Data format must match webhook node expectations',
'Error messages always include n8n_executions guidance - follow the suggested steps for efficient debugging',
'Execution IDs in error responses are crucial for debugging - always check for and use them'
],
relatedTools: ['n8n_executions', 'n8n_get_workflow', 'n8n_create_workflow']
}
};

View File

@@ -135,7 +135,7 @@ When working with Code nodes, always start by calling the relevant guide:
- n8n_list_workflows - List workflows with filters
- n8n_validate_workflow - Validate workflow by ID
- n8n_autofix_workflow - Auto-fix common issues
- n8n_trigger_webhook_workflow - Trigger via webhook
- n8n_test_workflow - Test/trigger workflows (webhook, form, chat, execute)
- n8n_executions - Unified execution management (action='get'/'list'/'delete')
- n8n_health_check - Check n8n API connectivity
- n8n_workflow_versions - Version history and rollback

View File

@@ -276,34 +276,58 @@ export const n8nManagementTools: ToolDefinition[] = [
// Execution Management Tools
{
name: 'n8n_trigger_webhook_workflow',
description: `Trigger workflow via webhook. Must be ACTIVE with Webhook node. Method must match config.`,
name: 'n8n_test_workflow',
description: `Test/trigger workflow execution. Auto-detects trigger type (webhook/form/chat). Supports: webhook (HTTP), form (fields), chat (message). Note: Only workflows with these trigger types can be executed externally.`,
inputSchema: {
type: 'object',
properties: {
webhookUrl: {
type: 'string',
description: 'Full webhook URL from n8n workflow (e.g., https://n8n.example.com/webhook/abc-def-ghi)'
workflowId: {
type: 'string',
description: 'Workflow ID to execute (required)'
},
httpMethod: {
type: 'string',
triggerType: {
type: 'string',
enum: ['webhook', 'form', 'chat'],
description: 'Trigger type. Auto-detected if not specified. Workflow must have a matching trigger node.'
},
// Webhook options
httpMethod: {
type: 'string',
enum: ['GET', 'POST', 'PUT', 'DELETE'],
description: 'HTTP method (must match webhook configuration, often GET)'
description: 'For webhook: HTTP method (default: from workflow config or POST)'
},
data: {
type: 'object',
description: 'Data to send with the webhook request'
webhookPath: {
type: 'string',
description: 'For webhook: override the webhook path'
},
headers: {
type: 'object',
description: 'Additional HTTP headers'
// Chat options
message: {
type: 'string',
description: 'For chat: message to send (required for chat triggers)'
},
waitForResponse: {
type: 'boolean',
description: 'Wait for workflow completion (default: true)'
sessionId: {
type: 'string',
description: 'For chat: session ID for conversation continuity'
},
// Common options
data: {
type: 'object',
description: 'Input data/payload for webhook, form fields, or execution data'
},
headers: {
type: 'object',
description: 'Custom HTTP headers'
},
timeout: {
type: 'number',
description: 'Timeout in ms (default: 120000)'
},
waitForResponse: {
type: 'boolean',
description: 'Wait for workflow completion (default: true)'
}
},
required: ['webhookUrl']
required: ['workflowId']
}
},
{

View File

@@ -16,6 +16,11 @@ export interface SimplifiedProperty {
placeholder?: string;
showWhen?: Record<string, any>;
usageHint?: string;
expectedFormat?: {
structure: Record<string, string>;
modes?: string[];
example: Record<string, any>;
};
}
export interface EssentialConfig {
@@ -322,7 +327,18 @@ export class PropertyFilter {
};
});
}
// Add expectedFormat for resourceLocator types - critical for correct configuration
if (prop.type === 'resourceLocator') {
const modes = prop.modes?.map((m: any) => m.name || m) || ['list', 'id'];
const defaultValue = prop.default?.value || 'your-resource-id';
simplified.expectedFormat = {
structure: { mode: 'string', value: 'string' },
modes,
example: { mode: 'id', value: defaultValue }
};
}
// Include simple display conditions (max 2 conditions)
if (prop.displayOptions?.show) {
const conditions = Object.keys(prop.displayOptions.show);

View File

@@ -757,7 +757,8 @@ export class WorkflowDiffEngine {
const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation);
// Use nullish coalescing to properly handle explicit 0 values
const targetInput = operation.targetInput ?? 'main';
// Default targetInput to sourceOutput to preserve connection type for AI connections (ai_tool, ai_memory, etc.)
const targetInput = operation.targetInput ?? sourceOutput;
const targetIndex = operation.targetIndex ?? 0;
// Initialize source node connections object

View File

@@ -1137,16 +1137,23 @@ export class WorkflowValidator {
}
// Check for AI Agent workflows
const aiAgentNodes = workflow.nodes.filter(n =>
n.type.toLowerCase().includes('agent') ||
const aiAgentNodes = workflow.nodes.filter(n =>
n.type.toLowerCase().includes('agent') ||
n.type.includes('langchain.agent')
);
if (aiAgentNodes.length > 0) {
// Check if AI agents have tools connected
// Tools connect TO the agent, so we need to find connections where the target is the agent
for (const agentNode of aiAgentNodes) {
const connections = workflow.connections[agentNode.name];
if (!connections?.ai_tool || connections.ai_tool.flat().filter(c => c).length === 0) {
// Search all connections to find ones targeting this agent via ai_tool
const hasToolConnected = Object.values(workflow.connections).some(sourceOutputs => {
const aiToolConnections = sourceOutputs.ai_tool;
if (!aiToolConnections) return false;
return aiToolConnections.flat().some(conn => conn && conn.node === agentNode.name);
});
if (!hasToolConnected) {
result.warnings.push({
type: 'warning',
nodeId: agentNode.id,

View File

@@ -0,0 +1,149 @@
/**
* Base trigger handler - abstract class for all trigger handlers
*/
import { z } from 'zod';
import { Workflow } from '../../types/n8n-api';
import { InstanceContext } from '../../types/instance-context';
import { N8nApiClient } from '../../services/n8n-api-client';
import { getN8nApiConfig } from '../../config/n8n-api';
import {
TriggerType,
TriggerResponse,
TriggerHandlerCapabilities,
DetectedTrigger,
BaseTriggerInput,
} from '../types';
/**
* Constructor type for trigger handlers
*/
export type TriggerHandlerConstructor = new (
client: N8nApiClient,
context?: InstanceContext
) => BaseTriggerHandler;
/**
* Abstract base class for all trigger handlers
*
* Each handler implements:
* - Input validation via Zod schema
* - Capability declaration (active workflow required, etc.)
* - Execution logic specific to the trigger type
*/
export abstract class BaseTriggerHandler<T extends BaseTriggerInput = BaseTriggerInput> {
protected client: N8nApiClient;
protected context?: InstanceContext;
/** The trigger type this handler supports */
abstract readonly triggerType: TriggerType;
/** Handler capabilities */
abstract readonly capabilities: TriggerHandlerCapabilities;
/** Zod schema for input validation */
abstract readonly inputSchema: z.ZodSchema<T>;
constructor(client: N8nApiClient, context?: InstanceContext) {
this.client = client;
this.context = context;
}
/**
* Validate input against schema
* @throws ZodError if validation fails
*/
validate(input: unknown): T {
return this.inputSchema.parse(input);
}
/**
* Execute the trigger
*
* @param input - Validated trigger input
* @param workflow - The workflow being triggered
* @param triggerInfo - Detected trigger information (may be undefined for 'execute' type)
*/
abstract execute(
input: T,
workflow: Workflow,
triggerInfo?: DetectedTrigger
): Promise<TriggerResponse>;
/**
* Get the n8n instance base URL from context or environment config
*/
protected getBaseUrl(): string | undefined {
// First try context (for multi-tenant scenarios)
if (this.context?.n8nApiUrl) {
return this.context.n8nApiUrl.replace(/\/api\/v1\/?$/, '');
}
// Fallback to environment config
const config = getN8nApiConfig();
if (config?.baseUrl) {
return config.baseUrl.replace(/\/api\/v1\/?$/, '');
}
return undefined;
}
/**
* Get the n8n API key from context or environment config
*/
protected getApiKey(): string | undefined {
// First try context (for multi-tenant scenarios)
if (this.context?.n8nApiKey) {
return this.context.n8nApiKey;
}
// Fallback to environment config
const config = getN8nApiConfig();
return config?.apiKey;
}
/**
* Normalize response to unified format
*/
protected normalizeResponse(
result: unknown,
input: T,
startTime: number,
extra?: Partial<TriggerResponse>
): TriggerResponse {
const endTime = Date.now();
const duration = endTime - startTime;
return {
success: true,
triggerType: this.triggerType,
workflowId: input.workflowId,
data: result,
metadata: {
duration,
},
...extra,
};
}
/**
* Create error response
*/
protected errorResponse(
input: BaseTriggerInput,
error: string,
startTime: number,
extra?: Partial<TriggerResponse>
): TriggerResponse {
const endTime = Date.now();
const duration = endTime - startTime;
return {
success: false,
triggerType: this.triggerType,
workflowId: input.workflowId,
error,
metadata: {
duration,
},
...extra,
};
}
}

View File

@@ -0,0 +1,141 @@
/**
* Chat trigger handler
*
* Handles chat-based workflow triggers:
* - POST to webhook endpoint with chat payload
* - Payload structure: { action: 'sendMessage', sessionId, chatInput }
* - Sync mode only (no SSE streaming)
*/
import { z } from 'zod';
import axios, { AxiosRequestConfig } from 'axios';
import { Workflow } from '../../types/n8n-api';
import {
TriggerType,
TriggerResponse,
TriggerHandlerCapabilities,
DetectedTrigger,
ChatTriggerInput,
} from '../types';
import { BaseTriggerHandler } from './base-handler';
import { buildTriggerUrl } from '../trigger-detector';
/**
* Zod schema for chat input validation
*/
const chatInputSchema = z.object({
workflowId: z.string(),
triggerType: z.literal('chat'),
message: z.string(),
sessionId: z.string().optional(),
data: z.record(z.unknown()).optional(),
headers: z.record(z.string()).optional(),
timeout: z.number().optional(),
waitForResponse: z.boolean().optional(),
});
/**
* Generate a unique session ID
*/
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
/**
* Chat trigger handler
*/
export class ChatHandler extends BaseTriggerHandler<ChatTriggerInput> {
readonly triggerType: TriggerType = 'chat';
readonly capabilities: TriggerHandlerCapabilities = {
requiresActiveWorkflow: true,
canPassInputData: true,
};
readonly inputSchema = chatInputSchema;
async execute(
input: ChatTriggerInput,
workflow: Workflow,
triggerInfo?: DetectedTrigger
): Promise<TriggerResponse> {
const startTime = Date.now();
try {
// Build chat webhook URL
const baseUrl = this.getBaseUrl();
if (!baseUrl) {
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
}
// Use trigger info to build URL or fallback to default pattern
let chatUrl: string;
if (triggerInfo?.webhookPath) {
chatUrl = buildTriggerUrl(baseUrl, triggerInfo, 'production');
} else {
// Default chat webhook path pattern
chatUrl = `${baseUrl.replace(/\/+$/, '')}/webhook/${input.workflowId}`;
}
// SSRF protection
const { SSRFProtection } = await import('../../utils/ssrf-protection');
const validation = await SSRFProtection.validateWebhookUrl(chatUrl);
if (!validation.valid) {
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime);
}
// Generate or use provided session ID
const sessionId = input.sessionId || generateSessionId();
// Build chat payload
const chatPayload = {
action: 'sendMessage',
sessionId,
chatInput: input.message,
// Include any additional data
...input.data,
};
// Build request config
const config: AxiosRequestConfig = {
method: 'POST',
url: chatUrl,
headers: {
'Content-Type': 'application/json',
...input.headers,
},
data: chatPayload,
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
validateStatus: (status) => status < 500,
};
// Make the request (sync mode - no streaming)
const response = await axios.request(config);
// Extract the chat response
const chatResponse = response.data;
return this.normalizeResponse(chatResponse, input, startTime, {
status: response.status,
statusText: response.statusText,
metadata: {
duration: Date.now() - startTime,
sessionId,
webhookPath: triggerInfo?.webhookPath,
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Try to extract execution ID from error if available
const errorDetails = (error as any)?.response?.data;
const executionId = errorDetails?.executionId || errorDetails?.id;
return this.errorResponse(input, errorMessage, startTime, {
executionId,
code: (error as any)?.code,
details: errorDetails,
});
}
}
}

View File

@@ -0,0 +1,460 @@
/**
* 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
* - 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 {
TriggerType,
TriggerResponse,
TriggerHandlerCapabilities,
DetectedTrigger,
FormTriggerInput,
} from '../types';
import { BaseTriggerHandler } from './base-handler';
/**
* Zod schema for form input validation
*/
const formInputSchema = z.object({
workflowId: z.string(),
triggerType: z.literal('form'),
formData: z.record(z.unknown()).optional(),
data: z.record(z.unknown()).optional(),
headers: z.record(z.string()).optional(),
timeout: z.number().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
*/
export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
readonly triggerType: TriggerType = 'form';
readonly capabilities: TriggerHandlerCapabilities = {
requiresActiveWorkflow: true,
canPassInputData: true,
};
readonly inputSchema = formInputSchema;
async execute(
input: FormTriggerInput,
workflow: Workflow,
triggerInfo?: DetectedTrigger
): 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),
},
});
}
// 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 inputFields = {
...input.data,
...input.formData,
};
// SSRF protection
const { SSRFProtection } = await import('../../utils/ssrf-protection');
const validation = await SSRFProtection.validateWebhookUrl(formUrl);
if (!validation.valid) {
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(),
...input.headers,
},
data: formData,
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
validateStatus: (status) => status < 500,
};
// Make the request
const response = await axios.request(config);
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';
// Try to extract execution ID from error if available
const errorDetails = (error as any)?.response?.data;
const executionId = errorDetails?.executionId || errorDetails?.id;
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),
},
});
}
}
}

View File

@@ -0,0 +1,125 @@
/**
* Webhook trigger handler
*
* Handles webhook-based workflow triggers:
* - Supports GET, POST, PUT, DELETE methods
* - Passes data as body (POST/PUT/DELETE) or query params (GET)
* - Includes SSRF protection
*/
import { z } from 'zod';
import { Workflow, WebhookRequest } from '../../types/n8n-api';
import {
TriggerType,
TriggerResponse,
TriggerHandlerCapabilities,
DetectedTrigger,
WebhookTriggerInput,
} from '../types';
import { BaseTriggerHandler } from './base-handler';
import { buildTriggerUrl } from '../trigger-detector';
/**
* Zod schema for webhook input validation
*/
const webhookInputSchema = z.object({
workflowId: z.string(),
triggerType: z.literal('webhook'),
httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(),
webhookPath: z.string().optional(),
data: z.record(z.unknown()).optional(),
headers: z.record(z.string()).optional(),
timeout: z.number().optional(),
waitForResponse: z.boolean().optional(),
});
/**
* Webhook trigger handler
*/
export class WebhookHandler extends BaseTriggerHandler<WebhookTriggerInput> {
readonly triggerType: TriggerType = 'webhook';
readonly capabilities: TriggerHandlerCapabilities = {
requiresActiveWorkflow: true,
supportedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
canPassInputData: true,
};
readonly inputSchema = webhookInputSchema;
async execute(
input: WebhookTriggerInput,
workflow: Workflow,
triggerInfo?: DetectedTrigger
): Promise<TriggerResponse> {
const startTime = Date.now();
try {
// Build webhook URL
const baseUrl = this.getBaseUrl();
if (!baseUrl) {
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
}
// Use provided webhook path or extract from trigger info
let webhookUrl: string;
if (input.webhookPath) {
// User provided explicit path
webhookUrl = `${baseUrl.replace(/\/+$/, '')}/webhook/${input.webhookPath}`;
} else if (triggerInfo?.webhookPath) {
// Use detected path from workflow
webhookUrl = buildTriggerUrl(baseUrl, triggerInfo, 'production');
} else {
return this.errorResponse(
input,
'No webhook path available. Provide webhookPath parameter or ensure workflow has a webhook trigger.',
startTime
);
}
// Determine HTTP method
const httpMethod = input.httpMethod || triggerInfo?.httpMethod || 'POST';
// SSRF protection - validate the webhook URL before making the request
const { SSRFProtection } = await import('../../utils/ssrf-protection');
const validation = await SSRFProtection.validateWebhookUrl(webhookUrl);
if (!validation.valid) {
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime);
}
// Build webhook request
const webhookRequest: WebhookRequest = {
webhookUrl,
httpMethod: httpMethod as 'GET' | 'POST' | 'PUT' | 'DELETE',
data: input.data,
headers: input.headers,
waitForResponse: input.waitForResponse ?? true,
};
// Trigger the webhook
const response = await this.client.triggerWebhook(webhookRequest);
return this.normalizeResponse(response, input, startTime, {
status: response.status,
statusText: response.statusText,
metadata: {
duration: Date.now() - startTime,
webhookPath: input.webhookPath || triggerInfo?.webhookPath,
httpMethod,
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Try to extract execution ID from error if available
const errorDetails = (error as any)?.details;
const executionId = errorDetails?.executionId || errorDetails?.id;
return this.errorResponse(input, errorMessage, startTime, {
executionId,
code: (error as any)?.code,
details: errorDetails,
});
}
}
}

46
src/triggers/index.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* Trigger system for n8n_test_workflow tool
*
* Provides extensible trigger handling for different n8n trigger types:
* - webhook: HTTP-based triggers
* - form: Form submission triggers
* - chat: Chat/AI triggers
*
* Note: n8n's public API does not support direct workflow execution.
* Only workflows with these trigger types can be triggered externally.
*/
// Types
export {
TriggerType,
BaseTriggerInput,
WebhookTriggerInput,
FormTriggerInput,
ChatTriggerInput,
TriggerInput,
TriggerResponse,
TriggerHandlerCapabilities,
DetectedTrigger,
TriggerDetectionResult,
TestWorkflowInput,
} from './types';
// Detector
export {
detectTriggerFromWorkflow,
buildTriggerUrl,
describeTrigger,
} from './trigger-detector';
// Registry
export {
TriggerRegistry,
initializeTriggerRegistry,
ensureRegistryInitialized,
} from './trigger-registry';
// Base handler
export {
BaseTriggerHandler,
TriggerHandlerConstructor,
} from './handlers/base-handler';

View File

@@ -0,0 +1,321 @@
/**
* Trigger detector - analyzes workflows to detect trigger type
*/
import { Workflow, WorkflowNode } from '../types/n8n-api';
import { normalizeNodeType } from '../utils/node-type-utils';
import { TriggerType, DetectedTrigger, TriggerDetectionResult } from './types';
/**
* Node type patterns for each trigger type
*/
const WEBHOOK_PATTERNS = [
'webhook',
'webhooktrigger',
];
const FORM_PATTERNS = [
'formtrigger',
'form',
];
const CHAT_PATTERNS = [
'chattrigger',
];
/**
* Detect the trigger type from a workflow
*
* Priority order:
* 1. Webhook trigger (most common for API access)
* 2. Chat trigger (AI-specific)
* 3. Form trigger
*
* Note: n8n's public API does not support direct workflow execution.
* Only workflows with webhook/form/chat triggers can be triggered externally.
*/
export function detectTriggerFromWorkflow(workflow: Workflow): TriggerDetectionResult {
if (!workflow.nodes || workflow.nodes.length === 0) {
return {
detected: false,
reason: 'Workflow has no nodes',
};
}
// Find all trigger nodes
const triggerNodes = workflow.nodes.filter(node => !node.disabled && isTriggerNodeType(node.type));
if (triggerNodes.length === 0) {
return {
detected: false,
reason: 'No trigger nodes found in workflow',
};
}
// Check for specific trigger types in priority order
for (const node of triggerNodes) {
const webhookTrigger = detectWebhookTrigger(node);
if (webhookTrigger) {
return {
detected: true,
trigger: webhookTrigger,
};
}
}
for (const node of triggerNodes) {
const chatTrigger = detectChatTrigger(node);
if (chatTrigger) {
return {
detected: true,
trigger: chatTrigger,
};
}
}
for (const node of triggerNodes) {
const formTrigger = detectFormTrigger(node);
if (formTrigger) {
return {
detected: true,
trigger: formTrigger,
};
}
}
// No externally-triggerable trigger found
return {
detected: false,
reason: `Workflow has trigger nodes but none support external triggering (found: ${triggerNodes.map(n => n.type).join(', ')}). Only webhook, form, and chat triggers can be triggered via the API.`,
};
}
/**
* Check if a node type is a trigger
*/
function isTriggerNodeType(nodeType: string): boolean {
const normalized = normalizeNodeType(nodeType).toLowerCase();
return (
normalized.includes('trigger') ||
normalized.includes('webhook') ||
normalized === 'nodes-base.start'
);
}
/**
* Detect webhook trigger and extract configuration
*/
function detectWebhookTrigger(node: WorkflowNode): DetectedTrigger | null {
const normalized = normalizeNodeType(node.type).toLowerCase();
const nodeName = normalized.split('.').pop() || '';
const isWebhook = WEBHOOK_PATTERNS.some(pattern =>
nodeName === pattern || nodeName.includes(pattern)
);
if (!isWebhook) {
return null;
}
// Extract webhook path from parameters
const params = node.parameters || {};
const webhookPath = extractWebhookPath(params, node.id, node.webhookId);
const httpMethod = extractHttpMethod(params);
return {
type: 'webhook',
node,
webhookPath,
httpMethod,
};
}
/**
* Detect form trigger and extract configuration
*/
function detectFormTrigger(node: WorkflowNode): DetectedTrigger | null {
const normalized = normalizeNodeType(node.type).toLowerCase();
const nodeName = normalized.split('.').pop() || '';
const isForm = FORM_PATTERNS.some(pattern =>
nodeName === pattern || nodeName.includes(pattern)
);
if (!isForm) {
return 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,
};
}
/**
* Detect chat trigger and extract configuration
*/
function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null {
const normalized = normalizeNodeType(node.type).toLowerCase();
const nodeName = normalized.split('.').pop() || '';
const isChat = CHAT_PATTERNS.some(pattern =>
nodeName === pattern || nodeName.includes(pattern)
);
if (!isChat) {
return null;
}
// Extract chat configuration
const params = node.parameters || {};
const responseMode = (params.options as any)?.responseMode || 'lastNode';
const webhookPath = extractWebhookPath(params, node.id, node.webhookId);
return {
type: 'chat',
node,
webhookPath,
chatConfig: {
responseMode,
},
};
}
/**
* 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 {
// Check for explicit path parameter
if (typeof params.path === 'string' && params.path) {
return params.path;
}
// Check for httpMethod specific path
if (typeof params.httpMethod === 'string') {
const methodPath = params[`path_${params.httpMethod.toLowerCase()}`];
if (typeof methodPath === 'string' && methodPath) {
return methodPath;
}
}
// 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;
}
/**
* Extract HTTP method from webhook parameters
*/
function extractHttpMethod(params: Record<string, unknown>): string {
if (typeof params.httpMethod === 'string') {
return params.httpMethod.toUpperCase();
}
return 'POST'; // Default to POST
}
/**
* Extract form field names from form trigger parameters
*/
function extractFormFields(params: Record<string, unknown>): string[] {
const fields: string[] = [];
// Check for formFields parameter (common pattern)
if (Array.isArray(params.formFields)) {
for (const field of params.formFields) {
if (field && typeof field.fieldLabel === 'string') {
fields.push(field.fieldLabel);
} else if (field && typeof field.fieldName === 'string') {
fields.push(field.fieldName);
}
}
}
// Check for fields in options
const options = params.options as Record<string, unknown> | undefined;
if (options && Array.isArray(options.formFields)) {
for (const field of options.formFields) {
if (field && typeof field.fieldLabel === 'string') {
fields.push(field.fieldLabel);
}
}
}
return fields;
}
/**
* Build the trigger URL based on detected trigger and n8n base URL
*
* @param baseUrl - n8n instance base URL (e.g., https://n8n.example.com)
* @param trigger - Detected trigger information
* @param mode - 'production' uses /webhook/, 'test' uses /webhook-test/
*/
export function buildTriggerUrl(
baseUrl: string,
trigger: DetectedTrigger,
mode: 'production' | 'test' = 'production'
): string {
const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); // Remove trailing slashes
switch (trigger.type) {
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/<webhookId> endpoint
const prefix = mode === 'test' ? 'form-test' : 'form';
const path = trigger.webhookPath || trigger.node.id;
return `${cleanBaseUrl}/${prefix}/${path}`;
}
default:
throw new Error(`Cannot build URL for trigger type: ${trigger.type}`);
}
}
/**
* Get a human-readable description of the detected trigger
*/
export function describeTrigger(trigger: DetectedTrigger): string {
switch (trigger.type) {
case 'webhook':
return `Webhook trigger (${trigger.httpMethod || 'POST'} /${trigger.webhookPath || trigger.node.id})`;
case 'form':
const fieldCount = trigger.formFields?.length || 0;
return `Form trigger (${fieldCount} fields)`;
case 'chat':
return `Chat trigger (${trigger.chatConfig?.responseMode || 'lastNode'} mode)`;
default:
return 'Unknown trigger';
}
}

View File

@@ -0,0 +1,118 @@
/**
* Trigger Registry - central registry for trigger handlers
*
* Uses the plugin pattern for extensibility:
* - Register handlers at startup
* - Get handler by trigger type
* - List all registered types
*/
import { N8nApiClient } from '../services/n8n-api-client';
import { InstanceContext } from '../types/instance-context';
import { TriggerType } from './types';
import { BaseTriggerHandler, TriggerHandlerConstructor } from './handlers/base-handler';
/**
* Central registry for trigger handlers
*/
export class TriggerRegistry {
private static handlers: Map<TriggerType, TriggerHandlerConstructor> = new Map();
private static initialized = false;
/**
* Register a trigger handler
*
* @param type - The trigger type this handler supports
* @param HandlerClass - The handler class constructor
*/
static register(type: TriggerType, HandlerClass: TriggerHandlerConstructor): void {
this.handlers.set(type, HandlerClass);
}
/**
* Get a handler instance for a trigger type
*
* @param type - The trigger type
* @param client - n8n API client
* @param context - Optional instance context
* @returns Handler instance or undefined if not registered
*/
static getHandler(
type: TriggerType,
client: N8nApiClient,
context?: InstanceContext
): BaseTriggerHandler | undefined {
const HandlerClass = this.handlers.get(type);
if (!HandlerClass) {
return undefined;
}
return new HandlerClass(client, context);
}
/**
* Check if a trigger type has a registered handler
*/
static hasHandler(type: TriggerType): boolean {
return this.handlers.has(type);
}
/**
* Get all registered trigger types
*/
static getRegisteredTypes(): TriggerType[] {
return Array.from(this.handlers.keys());
}
/**
* Clear all registered handlers (useful for testing)
*/
static clear(): void {
this.handlers.clear();
this.initialized = false;
}
/**
* Check if registry is initialized
*/
static isInitialized(): boolean {
return this.initialized;
}
/**
* Mark registry as initialized
*/
static markInitialized(): void {
this.initialized = true;
}
}
/**
* Initialize the registry with all handlers
* Called once at startup
*/
export async function initializeTriggerRegistry(): Promise<void> {
if (TriggerRegistry.isInitialized()) {
return;
}
// Import handlers dynamically to avoid circular dependencies
const { WebhookHandler } = await import('./handlers/webhook-handler');
const { FormHandler } = await import('./handlers/form-handler');
const { ChatHandler } = await import('./handlers/chat-handler');
// Register all handlers
TriggerRegistry.register('webhook', WebhookHandler);
TriggerRegistry.register('form', FormHandler);
TriggerRegistry.register('chat', ChatHandler);
TriggerRegistry.markInitialized();
}
/**
* Ensure registry is initialized (lazy initialization)
*/
export async function ensureRegistryInitialized(): Promise<void> {
if (!TriggerRegistry.isInitialized()) {
await initializeTriggerRegistry();
}
}

137
src/triggers/types.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Trigger system types for n8n_test_workflow tool
*
* Supports 3 trigger categories (all input-capable):
* - webhook: AI can pass HTTP body/headers/params
* - form: AI can pass form field values
* - chat: AI can pass message + sessionId
*
* Note: Direct workflow execution via API is not supported by n8n's public API.
* Workflows must have webhook/form/chat triggers to be executable externally.
*/
import { Workflow, WorkflowNode } from '../types/n8n-api';
/**
* Supported trigger types (all input-capable)
*/
export type TriggerType = 'webhook' | 'form' | 'chat';
/**
* Base input for all trigger handlers
*/
export interface BaseTriggerInput {
workflowId: string;
triggerType?: TriggerType;
data?: Record<string, unknown>;
headers?: Record<string, string>;
timeout?: number;
waitForResponse?: boolean;
}
/**
* Webhook-specific input
*/
export interface WebhookTriggerInput extends BaseTriggerInput {
triggerType: 'webhook';
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE';
webhookPath?: string;
}
/**
* Form-specific input
*/
export interface FormTriggerInput extends BaseTriggerInput {
triggerType: 'form';
formData?: Record<string, unknown>;
}
/**
* Chat-specific input (sync mode only)
*/
export interface ChatTriggerInput extends BaseTriggerInput {
triggerType: 'chat';
message: string;
sessionId?: string;
}
/**
* Discriminated union of all trigger inputs
*/
export type TriggerInput =
| WebhookTriggerInput
| FormTriggerInput
| ChatTriggerInput;
/**
* Unified response from all trigger handlers
*/
export interface TriggerResponse {
success: boolean;
triggerType: TriggerType;
workflowId: string;
executionId?: string;
status?: number;
statusText?: string;
data?: unknown;
error?: string;
code?: string;
details?: Record<string, unknown>;
metadata: {
duration: number;
webhookPath?: string;
sessionId?: string;
httpMethod?: string;
};
}
/**
* Handler capability flags
*/
export interface TriggerHandlerCapabilities {
/** Whether workflow must be active for this trigger */
requiresActiveWorkflow: boolean;
/** Supported HTTP methods (for webhook) */
supportedMethods?: string[];
/** Whether this handler can pass input data to workflow */
canPassInputData: boolean;
}
/**
* Detected trigger information from workflow analysis
*/
export interface DetectedTrigger {
type: TriggerType;
node: WorkflowNode;
webhookPath?: string;
httpMethod?: string;
formFields?: string[];
chatConfig?: {
responseMode?: string;
};
}
/**
* Result of trigger detection
*/
export interface TriggerDetectionResult {
detected: boolean;
trigger?: DetectedTrigger;
reason?: string;
}
/**
* Input for the MCP tool (before trigger type detection)
*/
export interface TestWorkflowInput {
workflowId: string;
triggerType?: TriggerType;
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE';
webhookPath?: string;
message?: string;
sessionId?: string;
data?: Record<string, unknown>;
headers?: Record<string, string>;
timeout?: number;
waitForResponse?: boolean;
}

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 {

View File

@@ -535,12 +535,12 @@ describe('Parameter Validation', () => {
{ name: 'n8n_validate_workflow', args: {}, expected: 'n8n_validate_workflow: Validation failed:\n • id: id is required' },
];
// n8n_update_partial_workflow and n8n_trigger_webhook_workflow use legacy validation
// n8n_update_partial_workflow and n8n_test_workflow use legacy validation
await expect(server.testExecuteTool('n8n_update_partial_workflow', {}))
.rejects.toThrow('Missing required parameters for n8n_update_partial_workflow: id, operations');
await expect(server.testExecuteTool('n8n_trigger_webhook_workflow', {}))
.rejects.toThrow('Missing required parameters for n8n_trigger_webhook_workflow: webhookUrl');
await expect(server.testExecuteTool('n8n_test_workflow', {}))
.rejects.toThrow('Missing required parameters for n8n_test_workflow: workflowId');
for (const tool of n8nToolsWithRequiredParams) {
await expect(server.testExecuteTool(tool.name, tool.args))

View File

@@ -406,5 +406,74 @@ describe('PropertyFilter', () => {
const complex = result.common.find(p => p.name === 'complex');
expect(complex?.default).toBeUndefined();
});
it('should add expectedFormat for resourceLocator type properties', () => {
const properties = [
{
name: 'channel',
type: 'resourceLocator',
displayName: 'Channel',
description: 'The channel to send message to',
modes: [
{ name: 'list', displayName: 'From List' },
{ name: 'id', displayName: 'By ID' },
{ name: 'url', displayName: 'By URL' }
],
default: { mode: 'list', value: '' }
}
];
const result = PropertyFilter.getEssentials(properties, 'nodes-base.slack');
const channelProp = result.common.find(p => p.name === 'channel');
expect(channelProp).toBeDefined();
expect(channelProp?.expectedFormat).toBeDefined();
expect(channelProp?.expectedFormat?.structure).toEqual({
mode: 'string',
value: 'string'
});
expect(channelProp?.expectedFormat?.modes).toEqual(['list', 'id', 'url']);
expect(channelProp?.expectedFormat?.example).toBeDefined();
expect(channelProp?.expectedFormat?.example.mode).toBe('id');
expect(channelProp?.expectedFormat?.example.value).toBeDefined();
});
it('should handle resourceLocator without modes array', () => {
const properties = [
{
name: 'resource',
type: 'resourceLocator',
displayName: 'Resource',
default: { mode: 'id', value: 'test-123' }
}
];
const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
const resourceProp = result.common.find(p => p.name === 'resource');
expect(resourceProp?.expectedFormat).toBeDefined();
// Should default to common modes
expect(resourceProp?.expectedFormat?.modes).toEqual(['list', 'id']);
expect(resourceProp?.expectedFormat?.example.value).toBe('test-123');
});
it('should handle resourceLocator with no default value', () => {
const properties = [
{
name: 'item',
type: 'resourceLocator',
displayName: 'Item',
modes: [{ name: 'search' }, { name: 'id' }]
}
];
const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
const itemProp = result.common.find(p => p.name === 'item');
expect(itemProp?.expectedFormat).toBeDefined();
expect(itemProp?.expectedFormat?.modes).toEqual(['search', 'id']);
// Should use fallback value
expect(itemProp?.expectedFormat?.example.value).toBe('your-resource-id');
});
});
});

View File

@@ -4665,4 +4665,223 @@ describe('WorkflowDiffEngine', () => {
expect(result.errors![0].message).toContain('executeWorkflowTrigger cannot activate workflows');
});
});
// Issue #458: AI connection type propagation
describe('AI Connection Type Propagation (Issue #458)', () => {
it('should propagate ai_tool connection type when targetInput is not specified', async () => {
const workflowWithAI = {
...baseWorkflow,
nodes: [
{
id: 'agent1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2.1,
position: [500, 300] as [number, number],
parameters: {}
},
{
id: 'tool1',
name: 'Calculator',
type: '@n8n/n8n-nodes-langchain.toolCalculator',
typeVersion: 1,
position: [300, 400] as [number, number],
parameters: {}
}
],
connections: {}
};
const operation: AddConnectionOperation = {
type: 'addConnection',
source: 'Calculator',
target: 'AI Agent',
sourceOutput: 'ai_tool'
// targetInput not specified - should default to sourceOutput ('ai_tool')
};
const request: WorkflowDiffRequest = {
id: 'test-workflow',
operations: [operation]
};
const result = await diffEngine.applyDiff(workflowWithAI as Workflow, request);
expect(result.success).toBe(true);
expect(result.workflow.connections['Calculator']).toBeDefined();
expect(result.workflow.connections['Calculator']['ai_tool']).toBeDefined();
// The inner type should be 'ai_tool', NOT 'main'
expect(result.workflow.connections['Calculator']['ai_tool'][0][0].type).toBe('ai_tool');
expect(result.workflow.connections['Calculator']['ai_tool'][0][0].node).toBe('AI Agent');
});
it('should propagate ai_languageModel connection type', async () => {
const workflowWithAI = {
...baseWorkflow,
nodes: [
{
id: 'agent1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2.1,
position: [500, 300] as [number, number],
parameters: {}
},
{
id: 'llm1',
name: 'OpenAI Chat Model',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1.2,
position: [300, 200] as [number, number],
parameters: {}
}
],
connections: {}
};
const operation: AddConnectionOperation = {
type: 'addConnection',
source: 'OpenAI Chat Model',
target: 'AI Agent',
sourceOutput: 'ai_languageModel'
};
const request: WorkflowDiffRequest = {
id: 'test-workflow',
operations: [operation]
};
const result = await diffEngine.applyDiff(workflowWithAI as Workflow, request);
expect(result.success).toBe(true);
expect(result.workflow.connections['OpenAI Chat Model']['ai_languageModel'][0][0].type).toBe('ai_languageModel');
});
it('should propagate ai_memory connection type', async () => {
const workflowWithAI = {
...baseWorkflow,
nodes: [
{
id: 'agent1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2.1,
position: [500, 300] as [number, number],
parameters: {}
},
{
id: 'memory1',
name: 'Window Buffer Memory',
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
typeVersion: 1.3,
position: [300, 500] as [number, number],
parameters: {}
}
],
connections: {}
};
const operation: AddConnectionOperation = {
type: 'addConnection',
source: 'Window Buffer Memory',
target: 'AI Agent',
sourceOutput: 'ai_memory'
};
const request: WorkflowDiffRequest = {
id: 'test-workflow',
operations: [operation]
};
const result = await diffEngine.applyDiff(workflowWithAI as Workflow, request);
expect(result.success).toBe(true);
expect(result.workflow.connections['Window Buffer Memory']['ai_memory'][0][0].type).toBe('ai_memory');
});
it('should allow explicit targetInput override for mixed connection types', async () => {
const workflowWithNodes = {
...baseWorkflow,
nodes: [
{
id: 'node1',
name: 'Source Node',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [300, 300] as [number, number],
parameters: {}
},
{
id: 'node2',
name: 'Target Node',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [500, 300] as [number, number],
parameters: {}
}
],
connections: {}
};
const operation: AddConnectionOperation = {
type: 'addConnection',
source: 'Source Node',
target: 'Target Node',
sourceOutput: 'main',
targetInput: 'main' // Explicit override
};
const request: WorkflowDiffRequest = {
id: 'test-workflow',
operations: [operation]
};
const result = await diffEngine.applyDiff(workflowWithNodes as Workflow, request);
expect(result.success).toBe(true);
expect(result.workflow.connections['Source Node']['main'][0][0].type).toBe('main');
});
it('should default to main for regular connections when sourceOutput is not specified', async () => {
const workflowWithNodes = {
...baseWorkflow,
nodes: [
{
id: 'node1',
name: 'Source Node',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [300, 300] as [number, number],
parameters: {}
},
{
id: 'node2',
name: 'Target Node',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [500, 300] as [number, number],
parameters: {}
}
],
connections: {}
};
const operation: AddConnectionOperation = {
type: 'addConnection',
source: 'Source Node',
target: 'Target Node'
// Neither sourceOutput nor targetInput specified - should default to 'main'
};
const request: WorkflowDiffRequest = {
id: 'test-workflow',
operations: [operation]
};
const result = await diffEngine.applyDiff(workflowWithNodes as Workflow, request);
expect(result.success).toBe(true);
expect(result.workflow.connections['Source Node']['main'][0][0].type).toBe('main');
});
});
});

View File

@@ -1329,6 +1329,37 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
});
it('should NOT warn about AI agents WITH tools properly connected', async () => {
const workflow = {
nodes: [
{
id: '1',
name: 'Calculator Tool',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {}
},
{
id: '2',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [300, 100],
parameters: {}
}
],
connections: {
'Calculator Tool': {
ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]]
}
}
} as any;
const result = await validator.validateWorkflow(workflow as any);
// Should NOT have warning about missing tools
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(false);
});
it('should suggest community package setting for AI tools', async () => {
const workflow = {
nodes: [

View File

@@ -0,0 +1,335 @@
/**
* Unit tests for BaseTriggerHandler
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BaseTriggerHandler } from '../../../../src/triggers/handlers/base-handler';
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
import { InstanceContext } from '../../../../src/types/instance-context';
import { Workflow } from '../../../../src/types/n8n-api';
import { TriggerType, TriggerResponse, TriggerHandlerCapabilities, BaseTriggerInput } from '../../../../src/triggers/types';
import { z } from 'zod';
// Mock getN8nApiConfig
vi.mock('../../../../src/config/n8n-api', () => ({
getN8nApiConfig: vi.fn(() => ({
baseUrl: 'https://env-n8n.example.com/api/v1',
apiKey: 'env-api-key',
})),
}));
// Create a concrete implementation for testing
class TestHandler extends BaseTriggerHandler {
readonly triggerType: TriggerType = 'webhook';
readonly capabilities: TriggerHandlerCapabilities = {
requiresActiveWorkflow: true,
canPassInputData: true,
};
readonly inputSchema = z.object({
workflowId: z.string(),
triggerType: z.literal('webhook'),
});
async execute(
input: BaseTriggerInput,
workflow: Workflow
): Promise<TriggerResponse> {
return {
success: true,
triggerType: this.triggerType,
workflowId: input.workflowId,
data: { test: 'data' },
metadata: { duration: 100 },
};
}
}
// Create mock client
const createMockClient = (): N8nApiClient => ({
getWorkflow: vi.fn(),
listWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
triggerWebhook: vi.fn(),
getExecution: vi.fn(),
listExecutions: vi.fn(),
deleteExecution: vi.fn(),
} as unknown as N8nApiClient);
describe('BaseTriggerHandler', () => {
let mockClient: N8nApiClient;
beforeEach(() => {
mockClient = createMockClient();
vi.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with client only', () => {
const handler = new TestHandler(mockClient);
expect(handler).toBeDefined();
expect(handler.triggerType).toBe('webhook');
});
it('should initialize with client and context', () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.com/api/v1',
n8nApiKey: 'test-key',
sessionId: 'test-session',
};
const handler = new TestHandler(mockClient, context);
expect(handler).toBeDefined();
});
});
describe('validate', () => {
it('should validate correct input', () => {
const handler = new TestHandler(mockClient);
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
const result = handler.validate(input);
expect(result).toEqual(input);
});
it('should throw ZodError for invalid input', () => {
const handler = new TestHandler(mockClient);
const input = {
workflowId: 123, // Wrong type
triggerType: 'webhook',
};
expect(() => handler.validate(input)).toThrow();
});
it('should throw ZodError for missing required fields', () => {
const handler = new TestHandler(mockClient);
const input = {
triggerType: 'webhook',
// Missing workflowId
};
expect(() => handler.validate(input)).toThrow();
});
});
describe('getBaseUrl', () => {
it('should return base URL from context', () => {
const context: InstanceContext = {
n8nApiUrl: 'https://context.n8n.com/api/v1',
n8nApiKey: 'context-key',
sessionId: 'test-session',
};
const handler = new TestHandler(mockClient, context);
const baseUrl = (handler as any).getBaseUrl();
expect(baseUrl).toBe('https://context.n8n.com');
});
it('should strip trailing slash and /api/v1 from context URL', () => {
const context: InstanceContext = {
n8nApiUrl: 'https://context.n8n.com/api/v1/',
n8nApiKey: 'context-key',
sessionId: 'test-session',
};
const handler = new TestHandler(mockClient, context);
const baseUrl = (handler as any).getBaseUrl();
expect(baseUrl).toBe('https://context.n8n.com');
});
it('should return base URL from environment config when no context', () => {
const handler = new TestHandler(mockClient);
const baseUrl = (handler as any).getBaseUrl();
expect(baseUrl).toBe('https://env-n8n.example.com');
});
it('should prefer context over environment config', () => {
const context: InstanceContext = {
n8nApiUrl: 'https://context.n8n.com/api/v1',
n8nApiKey: 'context-key',
sessionId: 'test-session',
};
const handler = new TestHandler(mockClient, context);
const baseUrl = (handler as any).getBaseUrl();
expect(baseUrl).toBe('https://context.n8n.com');
});
});
describe('getApiKey', () => {
it('should return API key from context', () => {
const context: InstanceContext = {
n8nApiUrl: 'https://context.n8n.com/api/v1',
n8nApiKey: 'context-api-key',
sessionId: 'test-session',
};
const handler = new TestHandler(mockClient, context);
const apiKey = (handler as any).getApiKey();
expect(apiKey).toBe('context-api-key');
});
it('should return API key from environment config when no context', () => {
const handler = new TestHandler(mockClient);
const apiKey = (handler as any).getApiKey();
expect(apiKey).toBe('env-api-key');
});
it('should prefer context over environment config', () => {
const context: InstanceContext = {
n8nApiUrl: 'https://context.n8n.com/api/v1',
n8nApiKey: 'context-key',
sessionId: 'test-session',
};
const handler = new TestHandler(mockClient, context);
const apiKey = (handler as any).getApiKey();
expect(apiKey).toBe('context-key');
});
});
describe('normalizeResponse', () => {
it('should create normalized success response', () => {
const handler = new TestHandler(mockClient);
const input: BaseTriggerInput = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
const startTime = Date.now() - 150;
const result = { data: 'test-result' };
const response = (handler as any).normalizeResponse(result, input, startTime);
expect(response.success).toBe(true);
expect(response.triggerType).toBe('webhook');
expect(response.workflowId).toBe('workflow-123');
expect(response.data).toEqual(result);
expect(response.metadata.duration).toBeGreaterThanOrEqual(150);
});
it('should merge extra fields into response', () => {
const handler = new TestHandler(mockClient);
const input: BaseTriggerInput = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
const startTime = Date.now();
const result = { data: 'test' };
const extra = {
executionId: 'exec-123',
status: 200,
};
const response = (handler as any).normalizeResponse(result, input, startTime, extra);
expect(response.executionId).toBe('exec-123');
expect(response.status).toBe(200);
});
it('should calculate duration correctly', () => {
const handler = new TestHandler(mockClient);
const input: BaseTriggerInput = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
const startTime = Date.now() - 500;
const response = (handler as any).normalizeResponse({}, input, startTime);
expect(response.metadata.duration).toBeGreaterThanOrEqual(500);
expect(response.metadata.duration).toBeLessThan(1000);
});
});
describe('errorResponse', () => {
it('should create error response', () => {
const handler = new TestHandler(mockClient);
const input: BaseTriggerInput = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
const startTime = Date.now() - 200;
const response = (handler as any).errorResponse(
input,
'Test error message',
startTime
);
expect(response.success).toBe(false);
expect(response.triggerType).toBe('webhook');
expect(response.workflowId).toBe('workflow-123');
expect(response.error).toBe('Test error message');
expect(response.metadata.duration).toBeGreaterThanOrEqual(200);
});
it('should merge extra error details', () => {
const handler = new TestHandler(mockClient);
const input: BaseTriggerInput = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
const startTime = Date.now();
const extra = {
code: 'ERR_TEST',
details: { reason: 'test reason' },
};
const response = (handler as any).errorResponse(
input,
'Error',
startTime,
extra
);
expect(response.code).toBe('ERR_TEST');
expect(response.details).toEqual({ reason: 'test reason' });
});
it('should calculate error duration correctly', () => {
const handler = new TestHandler(mockClient);
const input: BaseTriggerInput = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
const startTime = Date.now() - 750;
const response = (handler as any).errorResponse(input, 'Error', startTime);
expect(response.metadata.duration).toBeGreaterThanOrEqual(750);
expect(response.metadata.duration).toBeLessThan(1500);
});
});
describe('execute', () => {
it('should execute successfully', async () => {
const handler = new TestHandler(mockClient);
const input: BaseTriggerInput = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
const workflow: Workflow = {
id: 'workflow-123',
name: 'Test Workflow',
active: true,
nodes: [],
connections: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
settings: {},
staticData: undefined,
} as Workflow;
const response = await handler.execute(input, workflow);
expect(response.success).toBe(true);
expect(response.workflowId).toBe('workflow-123');
expect(response.data).toEqual({ test: 'data' });
});
});
});

View File

@@ -0,0 +1,569 @@
/**
* Unit tests for ChatHandler
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ChatHandler } from '../../../../src/triggers/handlers/chat-handler';
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
import { InstanceContext } from '../../../../src/types/instance-context';
import { Workflow } from '../../../../src/types/n8n-api';
import { DetectedTrigger } from '../../../../src/triggers/types';
import axios from 'axios';
// Mock getN8nApiConfig
vi.mock('../../../../src/config/n8n-api', () => ({
getN8nApiConfig: vi.fn(() => ({
baseUrl: 'https://test.n8n.com/api/v1',
apiKey: 'test-api-key',
})),
}));
// Mock SSRFProtection
vi.mock('../../../../src/utils/ssrf-protection', () => ({
SSRFProtection: {
validateWebhookUrl: vi.fn(async () => ({ valid: true, reason: '' })),
},
}));
// Mock buildTriggerUrl
vi.mock('../../../../src/triggers/trigger-detector', () => ({
buildTriggerUrl: vi.fn((baseUrl: string, trigger: any, mode: string) => {
return `${baseUrl}/webhook/${trigger.webhookPath}`;
}),
}));
// Mock axios
vi.mock('axios');
// Create mock client
const createMockClient = (): N8nApiClient => ({
getWorkflow: vi.fn(),
listWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
triggerWebhook: vi.fn(),
getExecution: vi.fn(),
listExecutions: vi.fn(),
deleteExecution: vi.fn(),
} as unknown as N8nApiClient);
// Create test workflow
const createWorkflow = (): Workflow => ({
id: 'workflow-123',
name: 'Chat Workflow',
active: true,
nodes: [
{
id: 'chat-node',
name: 'Chat',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {
path: 'ai-chat',
},
},
],
connections: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
settings: {},
staticData: undefined,
} as Workflow);
describe('ChatHandler', () => {
let mockClient: N8nApiClient;
let handler: ChatHandler;
beforeEach(async () => {
mockClient = createMockClient();
handler = new ChatHandler(mockClient);
vi.clearAllMocks();
// Reset SSRFProtection mock
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
valid: true,
reason: '',
});
// Reset axios mock
vi.mocked(axios.request).mockResolvedValue({
status: 200,
statusText: 'OK',
data: { response: 'Chat response' },
});
});
describe('initialization', () => {
it('should have correct trigger type', () => {
expect(handler.triggerType).toBe('chat');
});
it('should have correct capabilities', () => {
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
expect(handler.capabilities.canPassInputData).toBe(true);
});
});
describe('input validation', () => {
it('should validate correct chat input', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello AI!',
sessionId: 'session-123',
};
const result = handler.validate(input);
expect(result).toEqual(input);
});
it('should validate minimal input without sessionId', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello AI!',
};
const result = handler.validate(input);
expect(result.workflowId).toBe('workflow-123');
expect(result.message).toBe('Hello AI!');
expect(result.sessionId).toBeUndefined();
});
it('should reject invalid trigger type', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook',
message: 'Hello',
};
expect(() => handler.validate(input)).toThrow();
});
it('should reject missing message', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat',
};
expect(() => handler.validate(input)).toThrow();
});
it('should accept optional fields', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
data: { context: 'value' },
headers: { 'X-Custom': 'header' },
timeout: 60000,
waitForResponse: false,
};
const result = handler.validate(input);
expect(result.data).toEqual({ context: 'value' });
expect(result.headers).toEqual({ 'X-Custom': 'header' });
expect(result.timeout).toBe(60000);
expect(result.waitForResponse).toBe(false);
});
});
describe('execute', () => {
it('should execute chat with provided sessionId', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello AI!',
sessionId: 'custom-session',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(true);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
data: expect.objectContaining({
action: 'sendMessage',
sessionId: 'custom-session',
chatInput: 'Hello AI!',
}),
})
);
});
it('should generate sessionId when not provided', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello AI!',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(true);
expect(response.metadata?.sessionId).toMatch(/^session_\d+_[a-z0-9]+$/);
});
it('should use trigger info to build chat URL', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello AI!',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'custom-chat',
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/webhook/custom-chat'),
})
);
});
it('should use workflow ID as fallback when no trigger info', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello AI!',
};
const workflow = createWorkflow();
await handler.execute(input, workflow);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/webhook/workflow-123'),
})
);
});
it('should return error when base URL not available', async () => {
const handlerNoContext = new ChatHandler(mockClient, {} as InstanceContext);
// Mock getN8nApiConfig to return null
const { getN8nApiConfig } = await import('../../../../src/config/n8n-api');
vi.mocked(getN8nApiConfig).mockReturnValue(null as any);
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
};
const workflow = createWorkflow();
const response = await handlerNoContext.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.error).toContain('Cannot determine n8n base URL');
});
it('should handle SSRF protection rejection', async () => {
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
valid: false,
reason: 'Private IP address not allowed',
});
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
};
const workflow = createWorkflow();
const response = await handler.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.error).toContain('SSRF protection');
expect(response.error).toContain('Private IP address not allowed');
});
it('should include additional data in payload', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
data: {
userId: 'user-456',
context: 'support',
},
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'sendMessage',
chatInput: 'Hello',
userId: 'user-456',
context: 'support',
}),
})
);
});
it('should pass custom headers', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
headers: {
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token',
},
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
}),
})
);
});
it('should use custom timeout when provided', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
timeout: 90000,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 90000,
})
);
});
it('should use default timeout of 120000ms when waiting for response', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
waitForResponse: true,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 120000,
})
);
});
it('should use timeout of 30000ms when not waiting for response', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
waitForResponse: false,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 30000,
})
);
});
it('should return response with status and metadata', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello AI!',
sessionId: 'session-123',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
vi.mocked(axios.request).mockResolvedValue({
status: 200,
statusText: 'OK',
data: { response: 'AI reply', tokens: 150 },
});
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(true);
expect(response.status).toBe(200);
expect(response.statusText).toBe('OK');
expect(response.data).toEqual({ response: 'AI reply', tokens: 150 });
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
expect(response.metadata?.sessionId).toBe('session-123');
expect(response.metadata?.webhookPath).toBe('ai-chat');
});
it('should handle API errors gracefully', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
const apiError = new Error('Chat execution failed');
vi.mocked(axios.request).mockRejectedValue(apiError);
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(false);
expect(response.error).toBe('Chat execution failed');
});
it('should extract execution ID from error response', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
const apiError: any = new Error('Execution error');
apiError.response = {
data: {
executionId: 'exec-789',
error: 'Node failed',
},
};
vi.mocked(axios.request).mockRejectedValue(apiError);
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(false);
expect(response.executionId).toBe('exec-789');
expect(response.details).toEqual({
executionId: 'exec-789',
error: 'Node failed',
});
});
it('should handle error with code', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
const apiError: any = new Error('Timeout error');
apiError.code = 'ETIMEDOUT';
vi.mocked(axios.request).mockRejectedValue(apiError);
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(false);
expect(response.code).toBe('ETIMEDOUT');
});
it('should validate status codes less than 500', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat' as const,
message: 'Hello',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'chat',
node: workflow.nodes[0],
webhookPath: 'ai-chat',
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
validateStatus: expect.any(Function),
})
);
const config = vi.mocked(axios.request).mock.calls[0][0];
expect(config.validateStatus!(200)).toBe(true);
expect(config.validateStatus!(404)).toBe(true);
expect(config.validateStatus!(499)).toBe(true);
expect(config.validateStatus!(500)).toBe(false);
expect(config.validateStatus!(503)).toBe(false);
});
});
});

View File

@@ -0,0 +1,572 @@
/**
* Unit tests for FormHandler
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FormHandler } from '../../../../src/triggers/handlers/form-handler';
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
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', () => ({
getN8nApiConfig: vi.fn(() => ({
baseUrl: 'https://test.n8n.com/api/v1',
apiKey: 'test-api-key',
})),
}));
// Mock SSRFProtection
vi.mock('../../../../src/utils/ssrf-protection', () => ({
SSRFProtection: {
validateWebhookUrl: vi.fn(async () => ({ valid: true, reason: '' })),
},
}));
// Mock axios
vi.mock('axios');
// Create mock client
const createMockClient = (): N8nApiClient => ({
getWorkflow: vi.fn(),
listWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
triggerWebhook: vi.fn(),
getExecution: vi.fn(),
listExecutions: vi.fn(),
deleteExecution: vi.fn(),
} as unknown as N8nApiClient);
// Create test workflow
const createWorkflow = (): Workflow => ({
id: 'workflow-123',
name: 'Form Workflow',
active: true,
nodes: [
{
id: 'form-node',
name: 'Form Trigger',
type: 'n8n-nodes-base.formTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {
path: 'contact-form',
},
},
],
connections: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
settings: {},
staticData: undefined,
} as Workflow);
describe('FormHandler', () => {
let mockClient: N8nApiClient;
let handler: FormHandler;
beforeEach(async () => {
mockClient = createMockClient();
handler = new FormHandler(mockClient);
vi.clearAllMocks();
// Reset SSRFProtection mock
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
valid: true,
reason: '',
});
// Reset axios mock
vi.mocked(axios.request).mockResolvedValue({
status: 200,
statusText: 'OK',
data: { success: true, message: 'Form submitted' },
});
});
describe('initialization', () => {
it('should have correct trigger type', () => {
expect(handler.triggerType).toBe('form');
});
it('should have correct capabilities', () => {
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
expect(handler.capabilities.canPassInputData).toBe(true);
});
});
describe('input validation', () => {
it('should validate correct form input', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
formData: {
name: 'John Doe',
email: 'john@example.com',
},
};
const result = handler.validate(input);
expect(result).toEqual(input);
});
it('should validate minimal input without formData', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
};
const result = handler.validate(input);
expect(result.workflowId).toBe('workflow-123');
expect(result.triggerType).toBe('form');
expect(result.formData).toBeUndefined();
});
it('should reject invalid trigger type', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook',
};
expect(() => handler.validate(input)).toThrow();
});
it('should accept optional fields', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
formData: { field: 'value' },
data: { extra: 'data' },
headers: { 'X-Custom': 'header' },
timeout: 60000,
waitForResponse: false,
};
const result = handler.validate(input);
expect(result.formData).toEqual({ field: 'value' });
expect(result.data).toEqual({ extra: 'data' });
expect(result.headers).toEqual({ 'X-Custom': 'header' });
expect(result.timeout).toBe(60000);
expect(result.waitForResponse).toBe(false);
});
});
describe('execute', () => {
it('should execute form with provided formData using multipart/form-data', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
formData: {
name: 'Jane Doe',
email: 'jane@example.com',
message: 'Hello',
},
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(true);
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'),
})
);
});
it('should use form path from trigger info', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
formData: { field: 'value' },
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: {
id: 'form-node',
name: 'Form',
type: 'n8n-nodes-base.formTrigger',
typeVersion: 1,
position: [0, 0],
parameters: { path: 'custom-form' },
},
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/form/custom-form'),
})
);
});
it('should use workflow ID as fallback path', async () => {
const input = {
workflowId: 'workflow-456',
triggerType: 'form' as const,
formData: { field: 'value' },
};
const workflow = createWorkflow();
await handler.execute(input, workflow);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/form/workflow-456'),
})
);
});
it('should merge formData and data with formData taking precedence', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
data: {
field1: 'from data',
field2: 'from data',
},
formData: {
field2: 'from formData',
field3: 'from formData',
},
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
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);
});
it('should return error when base URL not available', async () => {
const handlerNoContext = new FormHandler(mockClient, {} as InstanceContext);
// Mock getN8nApiConfig to return null
const { getN8nApiConfig } = await import('../../../../src/config/n8n-api');
vi.mocked(getN8nApiConfig).mockReturnValue(null as any);
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
};
const workflow = createWorkflow();
const response = await handlerNoContext.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.error).toContain('Cannot determine n8n base URL');
});
it('should handle SSRF protection rejection', async () => {
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
valid: false,
reason: 'Private IP address not allowed',
});
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
};
const workflow = createWorkflow();
const response = await handler.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.error).toContain('SSRF protection');
expect(response.error).toContain('Private IP address not allowed');
});
it('should pass custom headers with multipart/form-data', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
formData: { field: 'value' },
headers: {
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token',
},
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
await handler.execute(input, workflow, triggerInfo);
const config = vi.mocked(axios.request).mock.calls[0][0];
expect(config.headers).toEqual(
expect.objectContaining({
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token',
// FormData sets multipart/form-data with boundary
'content-type': expect.stringContaining('multipart/form-data'),
})
);
});
it('should use custom timeout when provided', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
timeout: 90000,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 90000,
})
);
});
it('should use default timeout of 120000ms when waiting for response', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
waitForResponse: true,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 120000,
})
);
});
it('should use timeout of 30000ms when not waiting for response', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
waitForResponse: false,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 30000,
})
);
});
it('should return response with status and metadata', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
formData: { name: 'Test' },
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
vi.mocked(axios.request).mockResolvedValue({
status: 201,
statusText: 'Created',
data: { id: 'submission-123', status: 'processed' },
});
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(true);
expect(response.status).toBe(201);
expect(response.statusText).toBe('Created');
expect(response.data).toEqual({ id: 'submission-123', status: 'processed' });
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
});
it('should handle API errors gracefully', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
const apiError = new Error('Form submission failed');
vi.mocked(axios.request).mockRejectedValue(apiError);
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(false);
expect(response.error).toBe('Form submission failed');
});
it('should extract execution ID from error response', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
const apiError: any = new Error('Execution error');
apiError.response = {
data: {
id: 'exec-111',
error: 'Validation failed',
},
};
vi.mocked(axios.request).mockRejectedValue(apiError);
const response = await handler.execute(input, workflow, triggerInfo);
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),
})
);
});
it('should handle error with code', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
const apiError: any = new Error('Connection timeout');
apiError.code = 'ECONNABORTED';
vi.mocked(axios.request).mockRejectedValue(apiError);
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(false);
expect(response.code).toBe('ECONNABORTED');
});
it('should validate status codes less than 500', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
await handler.execute(input, workflow, triggerInfo);
expect(axios.request).toHaveBeenCalledWith(
expect.objectContaining({
validateStatus: expect.any(Function),
})
);
const config = vi.mocked(axios.request).mock.calls[0][0];
expect(config.validateStatus!(200)).toBe(true);
expect(config.validateStatus!(400)).toBe(true);
expect(config.validateStatus!(499)).toBe(true);
expect(config.validateStatus!(500)).toBe(false);
expect(config.validateStatus!(502)).toBe(false);
});
it('should handle empty formData', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
formData: {},
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
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);
});
it('should handle complex form data types via FormData', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'form' as const,
formData: {
name: 'Test User',
age: 30,
active: true,
tags: ['tag1', 'tag2'],
metadata: { key: 'value' },
},
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'form',
node: workflow.nodes[0],
};
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);
});
});
});

View File

@@ -0,0 +1,525 @@
/**
* Unit tests for WebhookHandler
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WebhookHandler } from '../../../../src/triggers/handlers/webhook-handler';
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
import { InstanceContext } from '../../../../src/types/instance-context';
import { Workflow, WebhookRequest } from '../../../../src/types/n8n-api';
import { DetectedTrigger } from '../../../../src/triggers/types';
// Mock getN8nApiConfig
vi.mock('../../../../src/config/n8n-api', () => ({
getN8nApiConfig: vi.fn(() => ({
baseUrl: 'https://test.n8n.com/api/v1',
apiKey: 'test-api-key',
})),
}));
// Mock SSRFProtection
vi.mock('../../../../src/utils/ssrf-protection', () => ({
SSRFProtection: {
validateWebhookUrl: vi.fn(async () => ({ valid: true, reason: '' })),
},
}));
// Mock buildTriggerUrl
vi.mock('../../../../src/triggers/trigger-detector', () => ({
buildTriggerUrl: vi.fn((baseUrl: string, trigger: any, mode: string) => {
return `${baseUrl}/webhook/${trigger.webhookPath}`;
}),
}));
// Create mock client
const createMockClient = (): N8nApiClient => ({
getWorkflow: vi.fn(),
listWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
triggerWebhook: vi.fn(),
getExecution: vi.fn(),
listExecutions: vi.fn(),
deleteExecution: vi.fn(),
} as unknown as N8nApiClient);
// Create test workflow
const createWorkflow = (): Workflow => ({
id: 'workflow-123',
name: 'Test Workflow',
active: true,
nodes: [
{
id: 'webhook-node',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0],
parameters: {
path: 'test-webhook',
httpMethod: 'POST',
},
},
],
connections: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
settings: {},
staticData: undefined,
} as Workflow);
describe('WebhookHandler', () => {
let mockClient: N8nApiClient;
let handler: WebhookHandler;
beforeEach(async () => {
mockClient = createMockClient();
handler = new WebhookHandler(mockClient);
vi.clearAllMocks();
// Import and reset mock
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
valid: true,
reason: '',
});
});
describe('initialization', () => {
it('should have correct trigger type', () => {
expect(handler.triggerType).toBe('webhook');
});
it('should have correct capabilities', () => {
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
expect(handler.capabilities.canPassInputData).toBe(true);
expect(handler.capabilities.supportedMethods).toEqual(['GET', 'POST', 'PUT', 'DELETE']);
});
});
describe('input validation', () => {
it('should validate correct webhook input', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
httpMethod: 'POST' as const,
webhookPath: 'test-path',
};
const result = handler.validate(input);
expect(result).toEqual(input);
});
it('should validate minimal input', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
};
const result = handler.validate(input);
expect(result.workflowId).toBe('workflow-123');
expect(result.triggerType).toBe('webhook');
});
it('should reject invalid trigger type', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'chat',
};
expect(() => handler.validate(input)).toThrow();
});
it('should reject invalid HTTP method', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook',
httpMethod: 'PATCH',
};
expect(() => handler.validate(input)).toThrow();
});
it('should accept optional fields', () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
data: { key: 'value' },
headers: { 'X-Custom': 'header' },
timeout: 60000,
waitForResponse: false,
};
const result = handler.validate(input);
expect(result.data).toEqual({ key: 'value' });
expect(result.headers).toEqual({ 'X-Custom': 'header' });
expect(result.timeout).toBe(60000);
expect(result.waitForResponse).toBe(false);
});
});
describe('execute', () => {
it('should execute webhook with provided path', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'custom-path',
httpMethod: 'POST' as const,
data: { test: 'data' },
};
const workflow = createWorkflow();
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: { result: 'success' },
});
const response = await handler.execute(input, workflow);
expect(response.success).toBe(true);
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
expect.objectContaining({
webhookUrl: expect.stringContaining('/webhook/custom-path'),
httpMethod: 'POST',
data: { test: 'data' },
})
);
});
it('should use trigger info when no explicit path provided', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'webhook',
node: workflow.nodes[0],
webhookPath: 'detected-path',
httpMethod: 'GET',
};
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: { result: 'success' },
});
const response = await handler.execute(input, workflow, triggerInfo);
expect(response.success).toBe(true);
expect(mockClient.triggerWebhook).toHaveBeenCalled();
});
it('should return error when no webhook path available', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
};
const workflow = createWorkflow();
const response = await handler.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.error).toContain('No webhook path available');
});
it('should return error when base URL not available', async () => {
const handlerNoContext = new WebhookHandler(mockClient, {} as InstanceContext);
// Mock getN8nApiConfig to return null
const { getN8nApiConfig } = await import('../../../../src/config/n8n-api');
vi.mocked(getN8nApiConfig).mockReturnValue(null as any);
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test',
};
const workflow = createWorkflow();
const response = await handlerNoContext.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.error).toContain('Cannot determine n8n base URL');
});
it('should handle SSRF protection rejection', async () => {
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
valid: false,
reason: 'Private IP address not allowed',
});
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
};
const workflow = createWorkflow();
const response = await handler.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.error).toContain('SSRF protection');
expect(response.error).toContain('Private IP address not allowed');
});
it('should use default POST method when not specified', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
};
const workflow = createWorkflow();
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: {},
});
await handler.execute(input, workflow);
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
expect.objectContaining({
httpMethod: 'POST',
})
);
});
it('should pass custom headers', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
headers: {
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token',
},
};
const workflow = createWorkflow();
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: {},
});
await handler.execute(input, workflow);
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token',
},
})
);
});
it('should set waitForResponse from input', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
waitForResponse: false,
};
const workflow = createWorkflow();
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 202,
statusText: 'Accepted',
data: {},
});
await handler.execute(input, workflow);
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
expect.objectContaining({
waitForResponse: false,
})
);
});
it('should default waitForResponse to true', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
};
const workflow = createWorkflow();
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: {},
});
await handler.execute(input, workflow);
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
expect.objectContaining({
waitForResponse: true,
})
);
});
it('should return response with status and metadata', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
httpMethod: 'POST' as const,
};
const workflow = createWorkflow();
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: { result: 'webhook response' },
});
const response = await handler.execute(input, workflow);
expect(response.success).toBe(true);
expect(response.status).toBe(200);
expect(response.statusText).toBe('OK');
expect(response.data).toEqual({ status: 200, statusText: 'OK', data: { result: 'webhook response' } });
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
expect(response.metadata?.webhookPath).toBe('test-path');
expect(response.metadata?.httpMethod).toBe('POST');
});
it('should handle API errors gracefully', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
};
const workflow = createWorkflow();
const apiError = new Error('Webhook execution failed');
vi.mocked(mockClient.triggerWebhook).mockRejectedValue(apiError);
const response = await handler.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.error).toBe('Webhook execution failed');
});
it('should extract execution ID from error details', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
};
const workflow = createWorkflow();
const apiError: any = new Error('Execution error');
apiError.details = {
executionId: 'exec-456',
message: 'Node execution failed',
};
vi.mocked(mockClient.triggerWebhook).mockRejectedValue(apiError);
const response = await handler.execute(input, workflow);
expect(response.success).toBe(false);
expect(response.executionId).toBe('exec-456');
expect(response.details).toEqual({
executionId: 'exec-456',
message: 'Node execution failed',
});
});
it('should support all HTTP methods', async () => {
const workflow = createWorkflow();
const methods: Array<'GET' | 'POST' | 'PUT' | 'DELETE'> = ['GET', 'POST', 'PUT', 'DELETE'];
for (const method of methods) {
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: {},
});
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
httpMethod: method,
};
const response = await handler.execute(input, workflow);
expect(response.success).toBe(true);
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
expect.objectContaining({
httpMethod: method,
})
);
}
});
it('should use httpMethod from trigger info when not in input', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'webhook',
node: workflow.nodes[0],
webhookPath: 'detected-path',
httpMethod: 'PUT',
};
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: {},
});
await handler.execute(input, workflow, triggerInfo);
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
expect.objectContaining({
httpMethod: 'PUT',
})
);
});
it('should prefer input httpMethod over trigger info', async () => {
const input = {
workflowId: 'workflow-123',
triggerType: 'webhook' as const,
webhookPath: 'test-path',
httpMethod: 'DELETE' as const,
};
const workflow = createWorkflow();
const triggerInfo: DetectedTrigger = {
type: 'webhook',
node: workflow.nodes[0],
webhookPath: 'detected-path',
httpMethod: 'GET',
};
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
status: 200,
statusText: 'OK',
data: {},
});
await handler.execute(input, workflow, triggerInfo);
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
expect.objectContaining({
httpMethod: 'DELETE',
})
);
});
});
});

View File

@@ -0,0 +1,331 @@
/**
* Unit tests for trigger detection
*/
import { describe, it, expect } from 'vitest';
import { detectTriggerFromWorkflow, buildTriggerUrl, describeTrigger } from '../../../src/triggers/trigger-detector';
import type { Workflow } from '../../../src/types/n8n-api';
// Helper to create a workflow with a specific trigger node
function createWorkflowWithTrigger(triggerType: string, params: Record<string, unknown> = {}): Workflow {
return {
id: 'test-workflow',
name: 'Test Workflow',
active: true,
nodes: [
{
id: 'trigger-node',
name: 'Trigger',
type: triggerType,
typeVersion: 1,
position: [0, 0],
parameters: params,
},
{
id: 'action-node',
name: 'Action',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [200, 0],
parameters: {},
},
],
connections: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
settings: {},
staticData: undefined,
} as Workflow;
}
describe('Trigger Detector', () => {
describe('detectTriggerFromWorkflow', () => {
describe('webhook detection', () => {
it('should detect n8n-nodes-base.webhook as webhook trigger', () => {
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
path: 'my-webhook',
httpMethod: 'POST',
});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(true);
expect(result.trigger?.type).toBe('webhook');
expect(result.trigger?.webhookPath).toBe('my-webhook');
expect(result.trigger?.httpMethod).toBe('POST');
});
it('should detect webhook node with httpMethod from parameters', () => {
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
path: 'get-data',
httpMethod: 'GET',
});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(true);
expect(result.trigger?.type).toBe('webhook');
expect(result.trigger?.httpMethod).toBe('GET');
});
it('should default httpMethod to POST when not specified', () => {
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
path: 'test-path',
});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(true);
expect(result.trigger?.type).toBe('webhook');
// Default is POST when not specified
expect(result.trigger?.httpMethod).toBe('POST');
});
});
describe('form detection', () => {
it('should detect n8n-nodes-base.formTrigger as form trigger', () => {
const workflow = createWorkflowWithTrigger('n8n-nodes-base.formTrigger', {
path: 'my-form',
});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(true);
expect(result.trigger?.type).toBe('form');
expect(result.trigger?.node?.parameters?.path).toBe('my-form');
});
});
describe('chat detection', () => {
it('should detect @n8n/n8n-nodes-langchain.chatTrigger as chat trigger', () => {
const workflow = createWorkflowWithTrigger('@n8n/n8n-nodes-langchain.chatTrigger', {
path: 'chat-endpoint',
});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(true);
expect(result.trigger?.type).toBe('chat');
});
it('should detect n8n-nodes-langchain.chatTrigger as chat trigger', () => {
const workflow = createWorkflowWithTrigger('n8n-nodes-langchain.chatTrigger', {
webhookPath: 'ai-chat',
});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(true);
expect(result.trigger?.type).toBe('chat');
});
});
describe('non-triggerable workflows', () => {
it('should return not detected for schedule trigger', () => {
const workflow = createWorkflowWithTrigger('n8n-nodes-base.scheduleTrigger', {
rule: { interval: [{ field: 'hours', value: 1 }] },
});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(false);
// Fallback reason may be undefined for non-input triggers
});
it('should return not detected for manual trigger', () => {
const workflow = createWorkflowWithTrigger('n8n-nodes-base.manualTrigger', {});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(false);
});
it('should return not detected for email trigger', () => {
const workflow = createWorkflowWithTrigger('n8n-nodes-base.emailReadImap', {
mailbox: 'INBOX',
});
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(false);
});
});
describe('workflows without triggers', () => {
it('should return not detected for workflow with no trigger node', () => {
const workflow: Workflow = {
id: 'test-workflow',
name: 'Test Workflow',
active: true,
nodes: [
{
id: 'action-node',
name: 'Action',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
settings: {},
staticData: undefined,
} as Workflow;
const result = detectTriggerFromWorkflow(workflow);
expect(result.detected).toBe(false);
});
});
});
describe('buildTriggerUrl', () => {
it('should build webhook URL correctly', () => {
const baseUrl = 'https://n8n.example.com';
const trigger = {
type: 'webhook' as const,
node: {
id: 'trigger',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: { path: 'my-webhook' },
},
webhookPath: 'my-webhook',
};
const url = buildTriggerUrl(baseUrl, trigger, 'production');
expect(url).toBe('https://n8n.example.com/webhook/my-webhook');
});
it('should build test webhook URL correctly', () => {
const baseUrl = 'https://n8n.example.com/';
const trigger = {
type: 'webhook' as const,
node: {
id: 'trigger',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: { path: 'test-path' },
},
webhookPath: 'test-path',
};
const url = buildTriggerUrl(baseUrl, trigger, 'test');
expect(url).toBe('https://n8n.example.com/webhook-test/test-path');
});
it('should build form URL with node ID when webhookPath not set', () => {
const baseUrl = 'https://n8n.example.com';
const trigger = {
type: 'form' as const,
node: {
id: 'trigger',
name: 'Form',
type: 'n8n-nodes-base.formTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: { path: 'my-form' },
},
// webhookPath is undefined - should use node.id
};
const url = buildTriggerUrl(baseUrl, trigger, 'production');
// When webhookPath is not set, uses node.id as fallback
expect(url).toContain('/form/');
});
it('should build chat URL correctly with /chat suffix', () => {
const baseUrl = 'https://n8n.example.com';
const trigger = {
type: 'chat' as const,
node: {
id: 'trigger',
name: 'Chat',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: { path: 'ai-chat' },
},
webhookPath: 'ai-chat',
};
const url = buildTriggerUrl(baseUrl, trigger, 'production');
// Chat triggers use /webhook/<webhookId>/chat endpoint
expect(url).toBe('https://n8n.example.com/webhook/ai-chat/chat');
});
});
describe('describeTrigger', () => {
it('should describe webhook trigger', () => {
const trigger = {
type: 'webhook' as const,
node: {
id: 'trigger',
name: 'My Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: { path: 'my-webhook' },
},
webhookPath: 'my-webhook',
httpMethod: 'POST' as const,
};
const description = describeTrigger(trigger);
// Case-insensitive check
expect(description.toLowerCase()).toContain('webhook');
expect(description).toContain('POST');
expect(description).toContain('my-webhook');
});
it('should describe form trigger', () => {
const trigger = {
type: 'form' as const,
node: {
id: 'trigger',
name: 'Contact Form',
type: 'n8n-nodes-base.formTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: { path: 'contact' },
},
webhookPath: 'contact',
};
const description = describeTrigger(trigger);
// Case-insensitive check
expect(description.toLowerCase()).toContain('form');
});
it('should describe chat trigger', () => {
const trigger = {
type: 'chat' as const,
node: {
id: 'trigger',
name: 'AI Chat',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
};
const description = describeTrigger(trigger);
// Case-insensitive check
expect(description.toLowerCase()).toContain('chat');
});
});
});

View File

@@ -0,0 +1,156 @@
/**
* Unit tests for trigger registry
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TriggerRegistry, initializeTriggerRegistry, ensureRegistryInitialized } from '../../../src/triggers/trigger-registry';
import type { N8nApiClient } from '../../../src/services/n8n-api-client';
// Mock N8nApiClient
const createMockClient = (): N8nApiClient => ({
getWorkflow: vi.fn(),
listWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
triggerWebhook: vi.fn(),
getExecution: vi.fn(),
listExecutions: vi.fn(),
deleteExecution: vi.fn(),
} as unknown as N8nApiClient);
describe('TriggerRegistry', () => {
describe('initialization', () => {
it('should initialize with all handlers registered', async () => {
await initializeTriggerRegistry();
const registeredTypes = TriggerRegistry.getRegisteredTypes();
expect(registeredTypes).toContain('webhook');
expect(registeredTypes).toContain('form');
expect(registeredTypes).toContain('chat');
expect(registeredTypes.length).toBe(3);
});
it('should not register duplicate handlers on multiple init calls', async () => {
await initializeTriggerRegistry();
const firstTypes = TriggerRegistry.getRegisteredTypes();
await initializeTriggerRegistry();
const secondTypes = TriggerRegistry.getRegisteredTypes();
expect(firstTypes.length).toBe(secondTypes.length);
});
});
describe('hasHandler', () => {
beforeEach(async () => {
await ensureRegistryInitialized();
});
it('should return true for webhook handler', () => {
expect(TriggerRegistry.hasHandler('webhook')).toBe(true);
});
it('should return true for form handler', () => {
expect(TriggerRegistry.hasHandler('form')).toBe(true);
});
it('should return true for chat handler', () => {
expect(TriggerRegistry.hasHandler('chat')).toBe(true);
});
it('should return false for unknown trigger type', () => {
expect(TriggerRegistry.hasHandler('unknown' as any)).toBe(false);
});
});
describe('getHandler', () => {
let mockClient: N8nApiClient;
beforeEach(async () => {
await ensureRegistryInitialized();
mockClient = createMockClient();
});
it('should return a webhook handler', () => {
const handler = TriggerRegistry.getHandler('webhook', mockClient);
expect(handler).toBeDefined();
expect(handler?.triggerType).toBe('webhook');
});
it('should return a form handler', () => {
const handler = TriggerRegistry.getHandler('form', mockClient);
expect(handler).toBeDefined();
expect(handler?.triggerType).toBe('form');
});
it('should return a chat handler', () => {
const handler = TriggerRegistry.getHandler('chat', mockClient);
expect(handler).toBeDefined();
expect(handler?.triggerType).toBe('chat');
});
it('should return undefined for unknown trigger type', () => {
const handler = TriggerRegistry.getHandler('unknown' as any, mockClient);
expect(handler).toBeUndefined();
});
});
describe('handler capabilities', () => {
let mockClient: N8nApiClient;
beforeEach(async () => {
await ensureRegistryInitialized();
mockClient = createMockClient();
});
it('webhook handler should require active workflow', () => {
const handler = TriggerRegistry.getHandler('webhook', mockClient);
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
expect(handler?.capabilities.canPassInputData).toBe(true);
});
it('form handler should require active workflow', () => {
const handler = TriggerRegistry.getHandler('form', mockClient);
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
expect(handler?.capabilities.canPassInputData).toBe(true);
});
it('chat handler should require active workflow', () => {
const handler = TriggerRegistry.getHandler('chat', mockClient);
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
expect(handler?.capabilities.canPassInputData).toBe(true);
});
});
describe('ensureRegistryInitialized', () => {
it('should be safe to call multiple times', async () => {
await ensureRegistryInitialized();
await ensureRegistryInitialized();
await ensureRegistryInitialized();
const types = TriggerRegistry.getRegisteredTypes();
expect(types.length).toBe(3);
});
it('should handle concurrent initialization calls', async () => {
const promises = [
ensureRegistryInitialized(),
ensureRegistryInitialized(),
ensureRegistryInitialized(),
];
await Promise.all(promises);
const types = TriggerRegistry.getRegisteredTypes();
expect(types.length).toBe(3);
});
});
});