mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 14:32:04 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33690c5650 | ||
|
|
ddf9556759 | ||
|
|
7d9b456887 | ||
|
|
2f5a857142 | ||
|
|
e7dd04b471 | ||
|
|
c7e7bda505 | ||
|
|
bac4936c6d | ||
|
|
25784142fe | ||
|
|
f770043d3d |
@@ -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
|
||||
---
|
||||
|
||||
|
||||
222
.github/workflows/dependency-check.yml
vendored
Normal file
222
.github/workflows/dependency-check.yml
vendored
Normal file
@@ -0,0 +1,222 @@
|
||||
name: Dependency Compatibility Check
|
||||
|
||||
# This workflow verifies that when users install n8n-mcp via npm (without lockfile),
|
||||
# they get compatible dependency versions. This catches issues like #440, #444, #446, #447, #450
|
||||
# where npm resolution gave users incompatible SDK/Zod versions.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/dependency-check.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/dependency-check.yml'
|
||||
# Allow manual trigger for debugging
|
||||
workflow_dispatch:
|
||||
# Run weekly to catch upstream dependency changes
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Every Monday at 6 AM UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
fresh-install-check:
|
||||
name: Fresh Install Dependency Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Pack package for testing
|
||||
run: npm pack
|
||||
|
||||
- name: Create fresh install test directory
|
||||
run: |
|
||||
mkdir -p /tmp/fresh-install-test
|
||||
cp n8n-mcp-*.tgz /tmp/fresh-install-test/
|
||||
|
||||
- name: Install package fresh (simulating user install)
|
||||
working-directory: /tmp/fresh-install-test
|
||||
run: |
|
||||
npm init -y
|
||||
# Install from tarball WITHOUT lockfile (simulates npm install n8n-mcp)
|
||||
npm install ./n8n-mcp-*.tgz
|
||||
|
||||
- name: Verify critical dependency versions
|
||||
working-directory: /tmp/fresh-install-test
|
||||
run: |
|
||||
echo "=== Dependency Version Check ==="
|
||||
echo ""
|
||||
|
||||
# Get actual resolved versions
|
||||
SDK_VERSION=$(npm list @modelcontextprotocol/sdk --json 2>/dev/null | jq -r '.dependencies["n8n-mcp"].dependencies["@modelcontextprotocol/sdk"].version // .dependencies["@modelcontextprotocol/sdk"].version // "not found"')
|
||||
ZOD_VERSION=$(npm list zod --json 2>/dev/null | jq -r '.dependencies["n8n-mcp"].dependencies.zod.version // .dependencies.zod.version // "not found"')
|
||||
|
||||
echo "MCP SDK version: $SDK_VERSION"
|
||||
echo "Zod version: $ZOD_VERSION"
|
||||
echo ""
|
||||
|
||||
# Check MCP SDK version - must be exactly 1.20.1
|
||||
if [[ "$SDK_VERSION" == "not found" ]]; then
|
||||
echo "❌ FAILED: Could not determine MCP SDK version!"
|
||||
echo " The dependency may not have been installed correctly."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$SDK_VERSION" != "1.20.1" ]]; then
|
||||
echo "❌ FAILED: MCP SDK version mismatch!"
|
||||
echo " Expected: 1.20.1"
|
||||
echo " Got: $SDK_VERSION"
|
||||
echo ""
|
||||
echo "This can cause runtime errors. See issues #440, #444, #446, #447, #450"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ MCP SDK version is correct: $SDK_VERSION"
|
||||
|
||||
# Check Zod version - must be 3.x (not 4.x, including pre-releases)
|
||||
if [[ "$ZOD_VERSION" == "not found" ]]; then
|
||||
echo "❌ FAILED: Could not determine Zod version!"
|
||||
echo " The dependency may not have been installed correctly."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ZOD_VERSION" =~ ^4\. ]]; then
|
||||
echo "❌ FAILED: Zod v4 detected - incompatible with MCP SDK 1.20.1!"
|
||||
echo " Expected: 3.x"
|
||||
echo " Got: $ZOD_VERSION"
|
||||
echo ""
|
||||
echo "Zod v4 causes '_zod' property errors. See issues #440, #444, #446, #447, #450"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Zod version is compatible: $ZOD_VERSION"
|
||||
|
||||
echo ""
|
||||
echo "=== All dependency checks passed ==="
|
||||
|
||||
- name: Test basic functionality
|
||||
working-directory: /tmp/fresh-install-test
|
||||
run: |
|
||||
echo "=== Basic Functionality Test ==="
|
||||
|
||||
# Create a simple test script
|
||||
cat > test-import.mjs << 'EOF'
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Test that the package can be required and basic tools work
|
||||
async function test() {
|
||||
console.log('Testing n8n-mcp package import...');
|
||||
|
||||
// Start the MCP server briefly to verify it initializes
|
||||
const serverPath = path.join(__dirname, 'node_modules/n8n-mcp/dist/mcp/index.js');
|
||||
|
||||
const proc = spawn('node', [serverPath], {
|
||||
env: { ...process.env, MCP_MODE: 'stdio' },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
// Send initialize request
|
||||
const initRequest = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
|
||||
proc.stdin.write(initRequest + '\n');
|
||||
|
||||
// Wait for response or timeout
|
||||
let output = '';
|
||||
let stderrOutput = '';
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderrOutput += data.toString();
|
||||
console.error('stderr:', data.toString());
|
||||
});
|
||||
|
||||
// Give it 5 seconds to respond
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
proc.kill();
|
||||
|
||||
// Check for Zod v4 compatibility errors (the bug we're testing for)
|
||||
const allOutput = output + stderrOutput;
|
||||
if (allOutput.includes('_zod') || allOutput.includes('Cannot read properties of undefined')) {
|
||||
console.error('❌ FAILED: Zod compatibility error detected!');
|
||||
console.error('This indicates the SDK/Zod version fix is not working.');
|
||||
console.error('See issues #440, #444, #446, #447, #450');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (output.includes('"result"')) {
|
||||
console.log('✅ MCP server initialized successfully');
|
||||
return true;
|
||||
} else {
|
||||
console.log('Output received:', output.substring(0, 500));
|
||||
// Server might not respond in stdio mode without proper framing
|
||||
// But if we got here without crashing, that's still good
|
||||
console.log('✅ MCP server started without errors');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
test()
|
||||
.then(() => {
|
||||
console.log('=== Basic functionality test passed ===');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('❌ Test failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
EOF
|
||||
|
||||
node test-import.mjs
|
||||
|
||||
- name: Generate dependency report
|
||||
if: always()
|
||||
working-directory: /tmp/fresh-install-test
|
||||
run: |
|
||||
echo "=== Full Dependency Tree ===" > dependency-report.txt
|
||||
npm list --all >> dependency-report.txt 2>&1 || true
|
||||
|
||||
echo "" >> dependency-report.txt
|
||||
echo "=== Critical Dependencies ===" >> dependency-report.txt
|
||||
npm list @modelcontextprotocol/sdk zod zod-to-json-schema >> dependency-report.txt 2>&1 || true
|
||||
|
||||
cat dependency-report.txt
|
||||
|
||||
- name: Upload dependency report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dependency-report-${{ github.run_number }}
|
||||
path: /tmp/fresh-install-test/dependency-report.txt
|
||||
retention-days: 30
|
||||
260
CHANGELOG.md
260
CHANGELOG.md
@@ -7,6 +7,266 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
**n8n_deploy_template: Deploy-First with Auto-Fix**
|
||||
|
||||
Improved the template deployment tool to deploy first, then automatically fix common issues. This change dramatically improves deployment success rates for templates with expression format issues.
|
||||
|
||||
#### Key Changes
|
||||
|
||||
1. **Deploy-First Behavior**
|
||||
- Templates are now deployed first without pre-validation
|
||||
- Auto-fix runs automatically after deployment (configurable via `autoFix` parameter)
|
||||
- Returns `fixesApplied` array showing all corrections made
|
||||
|
||||
2. **Fixed Expression Validator False Positive**
|
||||
- Fixed "nested expressions" detection that incorrectly flagged valid patterns
|
||||
- Multiple expressions in one string like `={{ $a }} text {{ $b }}` now correctly pass validation
|
||||
- Only truly nested patterns like `{{ {{ $json }} }}` are flagged as errors
|
||||
|
||||
3. **Fixed Zod Schema Validation**
|
||||
- Added missing `typeversion-upgrade` and `version-migration` fix types to autofix schema
|
||||
- Prevents silent validation failures when autofix runs
|
||||
|
||||
#### Usage
|
||||
|
||||
```javascript
|
||||
// Deploy with auto-fix (default behavior)
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
name: "My Workflow"
|
||||
})
|
||||
|
||||
// Deploy without auto-fix (not recommended)
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
autoFix: false
|
||||
})
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowId": "abc123",
|
||||
"name": "My Workflow",
|
||||
"fixesApplied": [
|
||||
{
|
||||
"node": "HTTP Request",
|
||||
"field": "url",
|
||||
"type": "expression-format",
|
||||
"before": "https://api.com/{{ $json.id }}",
|
||||
"after": "=https://api.com/{{ $json.id }}",
|
||||
"confidence": "high"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing Results
|
||||
|
||||
- 87% deployment success rate across 15 diverse templates
|
||||
- Auto-fix correctly adds `=` prefix to expressions missing it
|
||||
- Auto-fix correctly upgrades outdated typeVersions
|
||||
- Failed deployments are legitimate issues (missing community nodes, incomplete templates)
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.27.1] - 2025-11-29
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
**Issue #454: Docker Image Missing Zod Fix from #450**
|
||||
|
||||
Fixed Docker image build that was missing the pinned MCP SDK version, causing `n8n_create_workflow` Zod validation errors to persist in the 2.27.0 Docker image.
|
||||
|
||||
#### Root Cause
|
||||
|
||||
Two files were not updated when #450 pinned the SDK version in `package.json`:
|
||||
- `package.runtime.json` had `"@modelcontextprotocol/sdk": "^1.13.2"` instead of `"1.20.1"`
|
||||
- `Dockerfile` builder stage had `@modelcontextprotocol/sdk@^1.12.1` hardcoded
|
||||
|
||||
The Docker runtime stage uses `package.runtime.json` (not `package.json`), and the builder stage has hardcoded dependency versions.
|
||||
|
||||
#### Changes
|
||||
|
||||
- **package.runtime.json**: Updated SDK to pinned version `"1.20.1"` (no caret)
|
||||
- **Dockerfile**: Updated builder stage SDK to `@1.20.1` and pinned `zod@3.24.1`
|
||||
|
||||
#### Impact
|
||||
|
||||
- Docker images now include the correct MCP SDK version with Zod fix
|
||||
- `n8n_create_workflow` and other workflow tools work correctly in Docker deployments
|
||||
- No changes to functionality - this is a build configuration fix
|
||||
|
||||
Fixes #454
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.27.0] - 2025-11-28
|
||||
|
||||
### ✨ Features
|
||||
|
||||
**n8n_deploy_template Tool**
|
||||
|
||||
Added new tool for one-click deployment of n8n.io workflow templates directly to your n8n instance.
|
||||
|
||||
#### Key Features
|
||||
|
||||
- Fetches templates from n8n.io by ID
|
||||
- Automatically upgrades node typeVersions to latest supported
|
||||
- Validates workflow before deployment
|
||||
- Lists required credentials for configuration
|
||||
- Strips credential references (user configures in n8n UI)
|
||||
|
||||
#### Usage
|
||||
|
||||
```javascript
|
||||
n8n_deploy_template({
|
||||
templateId: 2639, // Required: template ID from n8n.io
|
||||
name: "My Custom Name", // Optional: custom workflow name
|
||||
autoUpgradeVersions: true, // Default: upgrade node versions
|
||||
validate: true, // Default: validate before deploy
|
||||
stripCredentials: true // Default: remove credential refs
|
||||
})
|
||||
```
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.26.5] - 2025-11-27
|
||||
|
||||
### 🔧 Fixed
|
||||
|
||||
- **Tools Documentation: Runtime Token Optimization**
|
||||
- Removed historical migration information from tool descriptions (e.g., "Replaces X, Y, Z...")
|
||||
- Removed version-specific references (v2.21.1, issue #357) that are not needed at runtime
|
||||
- Cleaned up consolidation comments in index.ts
|
||||
- Documentation now starts directly with functional content for better AI agent efficiency
|
||||
- Estimated savings: ~128 tokens per full documentation request
|
||||
- Affected tools: `get_node`, `validate_node`, `search_templates`, `n8n_executions`, `n8n_get_workflow`, `n8n_update_partial_workflow`
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.26.4] - 2025-11-26
|
||||
|
||||
### 🔧 Fixed
|
||||
|
||||
- **n8n 1.121 Compatibility**: Added support for new workflow settings introduced in n8n 1.121
|
||||
- Added `availableInMCP` (boolean) to settings whitelist - controls "Available in MCP" toggle
|
||||
- Added `callerPolicy` to settings whitelist - was already in schema but missing from sanitization
|
||||
- Both settings are now preserved during workflow updates instead of being silently stripped
|
||||
- Settings can be toggled via `updateSettings` operation: `{type: "updateSettings", settings: {availableInMCP: true}}`
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.26.3] - 2025-11-26
|
||||
|
||||
### 🔧 Fixed
|
||||
|
||||
- **Tools Documentation Gaps**: Addressed remaining documentation issues after v2.26.2 tool consolidation
|
||||
- Added missing `n8n_workflow_versions` documentation with all 6 modes (list, get, rollback, delete, prune, truncate)
|
||||
- Removed non-existent tools (`n8n_diagnostic`, `n8n_list_available_tools`) from documentation exports
|
||||
- Fixed 10+ outdated tool name references:
|
||||
- `get_node_essentials` → `get_node({detail: "standard"})`
|
||||
- `validate_node_operation` → `validate_node()`
|
||||
- `get_minimal` → `n8n_get_workflow({mode: "minimal"})`
|
||||
- Added missing `mode` and `verbose` parameters to `n8n_health_check` documentation
|
||||
- Added missing `mode` parameter to `get_template` documentation (nodes_only, structure, full)
|
||||
- Updated template count from "399+" to "2,700+" in `get_template`
|
||||
- Updated node count from "525" to "500+" in `search_nodes`
|
||||
- Fixed `relatedTools` arrays to remove references to non-existent tools
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.26.2] - 2025-11-25
|
||||
|
||||
### 🔧 Fixed
|
||||
|
||||
@@ -13,9 +13,9 @@ COPY tsconfig*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
echo '{}' > package.json && \
|
||||
npm install --no-save typescript@^5.8.3 @types/node@^22.15.30 @types/express@^5.0.3 \
|
||||
@modelcontextprotocol/sdk@^1.12.1 dotenv@^16.5.0 express@^5.1.0 axios@^1.10.0 \
|
||||
@modelcontextprotocol/sdk@1.20.1 dotenv@^16.5.0 express@^5.1.0 axios@^1.10.0 \
|
||||
n8n-workflow@^1.96.0 uuid@^11.0.5 @types/uuid@^10.0.0 \
|
||||
openai@^4.77.0 zod@^3.24.1 lru-cache@^11.2.1 @supabase/supabase-js@^2.57.4
|
||||
openai@^4.77.0 zod@3.24.1 lru-cache@^11.2.1 @supabase/supabase-js@^2.57.4
|
||||
|
||||
# Copy source and build
|
||||
COPY src ./src
|
||||
|
||||
37
README.md
37
README.md
@@ -36,10 +36,6 @@ AI results can be unpredictable. Protect your work!
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Get n8n-MCP running in minutes:
|
||||
|
||||
[](https://youtu.be/5CccjiLLyaY?si=Z62SBGlw9G34IQnQ&t=343)
|
||||
|
||||
### Option 1: Hosted Service (Easiest - No Setup!) ☁️
|
||||
|
||||
**The fastest way to try n8n-MCP** - no installation, no configuration:
|
||||
@@ -51,21 +47,7 @@ Get n8n-MCP running in minutes:
|
||||
- ✅ **Always up-to-date**: Latest n8n nodes and templates
|
||||
- ✅ **No infrastructure**: We handle everything
|
||||
|
||||
Just sign up, get your API key, and add to Claude Desktop:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"n8n-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@anthropic-ai/mcp-remote@latest", "https://mcp.n8n-mcp.com/sse"],
|
||||
"env": {
|
||||
"API_KEY": "your-api-key-from-dashboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Just sign up, get your API key, and connect your MCP client.
|
||||
|
||||
---
|
||||
|
||||
@@ -75,6 +57,10 @@ Prefer to run n8n-MCP yourself? Choose your deployment method:
|
||||
|
||||
### Option A: npx (Quick Local Setup) 🚀
|
||||
|
||||
Get n8n-MCP running in minutes:
|
||||
|
||||
[](https://youtu.be/5CccjiLLyaY?si=Z62SBGlw9G34IQnQ&t=343)
|
||||
|
||||
**Prerequisites:** [Node.js](https://nodejs.org/) installed on your system
|
||||
|
||||
```bash
|
||||
@@ -515,6 +501,9 @@ Complete guide for integrating n8n-MCP with Windsurf using project rules.
|
||||
### [Codex](./docs/CODEX_SETUP.md)
|
||||
Complete guide for integrating n8n-MCP with Codex.
|
||||
|
||||
### [Antigravity](./docs/ANTIGRAVITY_SETUP.md)
|
||||
Complete guide for integrating n8n-MCP with Antigravity.
|
||||
|
||||
## 🎓 Add Claude Skills (Optional)
|
||||
|
||||
Supercharge your n8n workflow building with specialized skills that teach AI how to build production-ready workflows!
|
||||
@@ -609,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
|
||||
|
||||
@@ -968,7 +957,7 @@ Once connected, Claude can use these powerful tools:
|
||||
- `searchMode: 'by_metadata'` - Filter by `complexity`, `requiredService`, `targetAudience`
|
||||
- **`get_template`** - Get complete workflow JSON (modes: nodes_only, structure, full)
|
||||
|
||||
### n8n Management Tools (12 tools - Requires API Configuration)
|
||||
### n8n Management Tools (13 tools - Requires API Configuration)
|
||||
These tools require `N8N_API_URL` and `N8N_API_KEY` in your configuration.
|
||||
|
||||
#### Workflow Management
|
||||
@@ -985,9 +974,13 @@ These tools require `N8N_API_URL` and `N8N_API_KEY` in your configuration.
|
||||
- **`n8n_validate_workflow`** - Validate workflows in n8n by ID
|
||||
- **`n8n_autofix_workflow`** - Automatically fix common workflow errors
|
||||
- **`n8n_workflow_versions`** - Manage version history and rollback
|
||||
- **`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
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
433
docs/ANTIGRAVITY_SETUP.md
Normal file
433
docs/ANTIGRAVITY_SETUP.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# Antigravity Setup
|
||||
|
||||
:white_check_mark: This n8n MCP server is compatible with Antigravity (Chat in IDE).
|
||||
|
||||
## Preconditions
|
||||
|
||||
Assuming you've already deployed the n8n MCP server locally and connected it to the n8n API, and it's available at:
|
||||
`http://localhost:5678`
|
||||
|
||||
Or if you are using `https://n8n.your.production.url/` then just replace the URLs in the below code.
|
||||
|
||||
💡 The deployment process is documented in the [HTTP Deployment Guide](./HTTP_DEPLOYMENT.md).
|
||||
|
||||
## Step 1
|
||||
|
||||
Add n8n-mcp globally: `npm install -g n8n-mcp`
|
||||
|
||||
## Step 2
|
||||
|
||||
Add MCP server by clicking three dots `...` on the top right of chat, and click MCP Servers.
|
||||
Then click on "Manage MCP Servers" and then click on "View raw config" and `C:\Users\<USER_NAME>\.gemini\antigravity\mcp_config.json` will be opened.
|
||||
|
||||
## Step 3
|
||||
|
||||
Add the following code to: `C:\Users\<USER_NAME>\.gemini\antigravity\mcp_config.json` and save the file.
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"n8n-mcp": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"C:\\Users\\<USER_NAME>\\AppData\\Roaming\\npm\\node_modules\\n8n-mcp\\dist\\mcp\\index.js"
|
||||
],
|
||||
"env": {
|
||||
"MCP_MODE": "stdio",
|
||||
"LOG_LEVEL": "error",
|
||||
"DISABLE_CONSOLE_OUTPUT": "true",
|
||||
"N8N_API_URL": "http://localhost:5678",
|
||||
"N8N_BASE_URL": "http://localhost:5678",
|
||||
"N8N_API_KEY": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4
|
||||
|
||||
Go back to "Manage MCP servers" and click referesh. The n8n-mcp will be enabled with all the tools.
|
||||
|
||||
|
||||
## Step 5
|
||||
|
||||
For the best results when using n8n-MCP with Antigravity, use these enhanced system instructions (create `AGENTS.md` in the root directory of the project, `AGENTS.md` is the file standard for giving special instructions in Antigravity):
|
||||
|
||||
````markdown
|
||||
You are an expert in n8n automation software using n8n-MCP tools. Your role is to design, build, and validate n8n workflows with maximum accuracy and efficiency.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Silent Execution
|
||||
CRITICAL: Execute tools without commentary. Only respond AFTER all tools complete.
|
||||
|
||||
❌ BAD: "Let me search for Slack nodes... Great! Now let me get details..."
|
||||
✅ GOOD: [Execute search_nodes and get_node in parallel, then respond]
|
||||
|
||||
### 2. Parallel Execution
|
||||
When operations are independent, execute them in parallel for maximum performance.
|
||||
|
||||
✅ GOOD: Call search_nodes, list_nodes, and search_templates simultaneously
|
||||
❌ BAD: Sequential tool calls (await each one before the next)
|
||||
|
||||
### 3. Templates First
|
||||
ALWAYS check templates before building from scratch (2,709 available).
|
||||
|
||||
### 4. Multi-Level Validation
|
||||
Use validate_node(mode='minimal') → validate_node(mode='full') → validate_workflow pattern.
|
||||
|
||||
### 5. Never Trust Defaults
|
||||
⚠️ CRITICAL: Default parameter values are the #1 source of runtime failures.
|
||||
ALWAYS explicitly configure ALL parameters that control node behavior.
|
||||
|
||||
## Workflow Process
|
||||
|
||||
1. **Start**: Call `tools_documentation()` for best practices
|
||||
|
||||
2. **Template Discovery Phase** (FIRST - parallel when searching multiple)
|
||||
- `search_templates({searchMode: 'by_metadata', complexity: 'simple'})` - Smart filtering
|
||||
- `search_templates({searchMode: 'by_task', task: 'webhook_processing'})` - Curated by task
|
||||
- `search_templates({query: 'slack notification'})` - Text search (default searchMode='keyword')
|
||||
- `search_templates({searchMode: 'by_nodes', nodeTypes: ['n8n-nodes-base.slack']})` - By node type
|
||||
|
||||
**Filtering strategies**:
|
||||
- Beginners: `complexity: "simple"` + `maxSetupMinutes: 30`
|
||||
- By role: `targetAudience: "marketers"` | `"developers"` | `"analysts"`
|
||||
- By time: `maxSetupMinutes: 15` for quick wins
|
||||
- By service: `requiredService: "openai"` for compatibility
|
||||
|
||||
3. **Node Discovery** (if no suitable template - parallel execution)
|
||||
- Think deeply about requirements. Ask clarifying questions if unclear.
|
||||
- `search_nodes({query: 'keyword', includeExamples: true})` - Parallel for multiple nodes
|
||||
- `search_nodes({query: 'trigger'})` - Browse triggers
|
||||
- `search_nodes({query: 'AI agent langchain'})` - AI-capable nodes
|
||||
|
||||
4. **Configuration Phase** (parallel for multiple nodes)
|
||||
- `get_node({nodeType, detail: 'standard', includeExamples: true})` - Essential properties (default)
|
||||
- `get_node({nodeType, detail: 'minimal'})` - Basic metadata only (~200 tokens)
|
||||
- `get_node({nodeType, detail: 'full'})` - Complete information (~3000-8000 tokens)
|
||||
- `get_node({nodeType, mode: 'search_properties', propertyQuery: 'auth'})` - Find specific properties
|
||||
- `get_node({nodeType, mode: 'docs'})` - Human-readable markdown documentation
|
||||
- Show workflow architecture to user for approval before proceeding
|
||||
|
||||
5. **Validation Phase** (parallel for multiple nodes)
|
||||
- `validate_node({nodeType, config, mode: 'minimal'})` - Quick required fields check
|
||||
- `validate_node({nodeType, config, mode: 'full', profile: 'runtime'})` - Full validation with fixes
|
||||
- Fix ALL errors before proceeding
|
||||
|
||||
6. **Building Phase**
|
||||
- If using template: `get_template(templateId, {mode: "full"})`
|
||||
- **MANDATORY ATTRIBUTION**: "Based on template by **[author.name]** (@[username]). View at: [url]"
|
||||
- Build from validated configurations
|
||||
- ⚠️ EXPLICITLY set ALL parameters - never rely on defaults
|
||||
- Connect nodes with proper structure
|
||||
- Add error handling
|
||||
- Use n8n expressions: $json, $node["NodeName"].json
|
||||
- Build in artifact (unless deploying to n8n instance)
|
||||
|
||||
7. **Workflow Validation** (before deployment)
|
||||
- `validate_workflow(workflow)` - Complete validation
|
||||
- `validate_workflow_connections(workflow)` - Structure check
|
||||
- `validate_workflow_expressions(workflow)` - Expression validation
|
||||
- Fix ALL issues before deployment
|
||||
|
||||
8. **Deployment** (if n8n API configured)
|
||||
- `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
|
||||
|
||||
## Critical Warnings
|
||||
|
||||
### ⚠️ Never Trust Defaults
|
||||
Default values cause runtime failures. Example:
|
||||
```json
|
||||
// ❌ FAILS at runtime
|
||||
{resource: "message", operation: "post", text: "Hello"}
|
||||
|
||||
// ✅ WORKS - all parameters explicit
|
||||
{resource: "message", operation: "post", select: "channel", channelId: "C123", text: "Hello"}
|
||||
```
|
||||
|
||||
### ⚠️ Example Availability
|
||||
`includeExamples: true` returns real configurations from workflow templates.
|
||||
- Coverage varies by node popularity
|
||||
- When no examples available, use `get_node` + `validate_node({mode: 'minimal'})`
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
### Level 1 - Quick Check (before building)
|
||||
`validate_node({nodeType, config, mode: 'minimal'})` - Required fields only (<100ms)
|
||||
|
||||
### Level 2 - Comprehensive (before building)
|
||||
`validate_node({nodeType, config, mode: 'full', profile: 'runtime'})` - Full validation with fixes
|
||||
|
||||
### Level 3 - Complete (after building)
|
||||
`validate_workflow(workflow)` - Connections, expressions, AI tools
|
||||
|
||||
### Level 4 - Post-Deployment
|
||||
1. `n8n_validate_workflow({id})` - Validate deployed workflow
|
||||
2. `n8n_autofix_workflow({id})` - Auto-fix common errors
|
||||
3. `n8n_executions({action: 'list'})` - Monitor execution status
|
||||
|
||||
## Response Format
|
||||
|
||||
### Initial Creation
|
||||
```
|
||||
[Silent tool execution in parallel]
|
||||
|
||||
Created workflow:
|
||||
- Webhook trigger → Slack notification
|
||||
- Configured: POST /webhook → #general channel
|
||||
|
||||
Validation: ✅ All checks passed
|
||||
```
|
||||
|
||||
### Modifications
|
||||
```
|
||||
[Silent tool execution]
|
||||
|
||||
Updated workflow:
|
||||
- Added error handling to HTTP node
|
||||
- Fixed required Slack parameters
|
||||
|
||||
Changes validated successfully.
|
||||
```
|
||||
|
||||
## Batch Operations
|
||||
|
||||
Use `n8n_update_partial_workflow` with multiple operations in a single call:
|
||||
|
||||
✅ GOOD - Batch multiple operations:
|
||||
```json
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf-123",
|
||||
operations: [
|
||||
{type: "updateNode", nodeId: "slack-1", changes: {...}},
|
||||
{type: "updateNode", nodeId: "http-1", changes: {...}},
|
||||
{type: "cleanStaleConnections"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
❌ BAD - Separate calls:
|
||||
```json
|
||||
n8n_update_partial_workflow({id: "wf-123", operations: [{...}]})
|
||||
n8n_update_partial_workflow({id: "wf-123", operations: [{...}]})
|
||||
```
|
||||
|
||||
### CRITICAL: addConnection Syntax
|
||||
|
||||
The `addConnection` operation requires **four separate string parameters**. Common mistakes cause misleading errors.
|
||||
|
||||
❌ WRONG - Object format (fails with "Expected string, received object"):
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"connection": {
|
||||
"source": {"nodeId": "node-1", "outputIndex": 0},
|
||||
"destination": {"nodeId": "node-2", "inputIndex": 0}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
❌ WRONG - Combined string (fails with "Source node not found"):
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "node-1:main:0",
|
||||
"target": "node-2:main:0"
|
||||
}
|
||||
```
|
||||
|
||||
✅ CORRECT - Four separate string parameters:
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "node-id-string",
|
||||
"target": "target-node-id-string",
|
||||
"sourcePort": "main",
|
||||
"targetPort": "main"
|
||||
}
|
||||
```
|
||||
|
||||
**Reference**: [GitHub Issue #327](https://github.com/czlonkowski/n8n-mcp/issues/327)
|
||||
|
||||
### ⚠️ CRITICAL: IF Node Multi-Output Routing
|
||||
|
||||
IF nodes have **two outputs** (TRUE and FALSE). Use the **`branch` parameter** to route to the correct output:
|
||||
|
||||
✅ CORRECT - Route to TRUE branch (when condition is met):
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "if-node-id",
|
||||
"target": "success-handler-id",
|
||||
"sourcePort": "main",
|
||||
"targetPort": "main",
|
||||
"branch": "true"
|
||||
}
|
||||
```
|
||||
|
||||
✅ CORRECT - Route to FALSE branch (when condition is NOT met):
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "if-node-id",
|
||||
"target": "failure-handler-id",
|
||||
"sourcePort": "main",
|
||||
"targetPort": "main",
|
||||
"branch": "false"
|
||||
}
|
||||
```
|
||||
|
||||
**Common Pattern** - Complete IF node routing:
|
||||
```json
|
||||
n8n_update_partial_workflow({
|
||||
id: "workflow-id",
|
||||
operations: [
|
||||
{type: "addConnection", source: "If Node", target: "True Handler", sourcePort: "main", targetPort: "main", branch: "true"},
|
||||
{type: "addConnection", source: "If Node", target: "False Handler", sourcePort: "main", targetPort: "main", branch: "false"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
**Note**: Without the `branch` parameter, both connections may end up on the same output, causing logic errors!
|
||||
|
||||
### removeConnection Syntax
|
||||
|
||||
Use the same four-parameter format:
|
||||
```json
|
||||
{
|
||||
"type": "removeConnection",
|
||||
"source": "source-node-id",
|
||||
"target": "target-node-id",
|
||||
"sourcePort": "main",
|
||||
"targetPort": "main"
|
||||
}
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
### Template-First Approach
|
||||
|
||||
```
|
||||
// STEP 1: Template Discovery (parallel execution)
|
||||
[Silent execution]
|
||||
search_templates({
|
||||
searchMode: 'by_metadata',
|
||||
requiredService: 'slack',
|
||||
complexity: 'simple',
|
||||
targetAudience: 'marketers'
|
||||
})
|
||||
search_templates({searchMode: 'by_task', task: 'slack_integration'})
|
||||
|
||||
// STEP 2: Use template
|
||||
get_template(templateId, {mode: 'full'})
|
||||
validate_workflow(workflow)
|
||||
|
||||
// Response after all tools complete:
|
||||
"Found template by **David Ashby** (@cfomodz).
|
||||
View at: https://n8n.io/workflows/2414
|
||||
|
||||
Validation: ✅ All checks passed"
|
||||
```
|
||||
|
||||
### Building from Scratch (if no template)
|
||||
|
||||
```
|
||||
// STEP 1: Discovery (parallel execution)
|
||||
[Silent execution]
|
||||
search_nodes({query: 'slack', includeExamples: true})
|
||||
search_nodes({query: 'communication trigger'})
|
||||
|
||||
// STEP 2: Configuration (parallel execution)
|
||||
[Silent execution]
|
||||
get_node({nodeType: 'n8n-nodes-base.slack', detail: 'standard', includeExamples: true})
|
||||
get_node({nodeType: 'n8n-nodes-base.webhook', detail: 'standard', includeExamples: true})
|
||||
|
||||
// STEP 3: Validation (parallel execution)
|
||||
[Silent execution]
|
||||
validate_node({nodeType: 'n8n-nodes-base.slack', config, mode: 'minimal'})
|
||||
validate_node({nodeType: 'n8n-nodes-base.slack', config: fullConfig, mode: 'full', profile: 'runtime'})
|
||||
|
||||
// STEP 4: Build
|
||||
// Construct workflow with validated configs
|
||||
// ⚠️ Set ALL parameters explicitly
|
||||
|
||||
// STEP 5: Validate
|
||||
[Silent execution]
|
||||
validate_workflow(workflowJson)
|
||||
|
||||
// Response after all tools complete:
|
||||
"Created workflow: Webhook → Slack
|
||||
Validation: ✅ Passed"
|
||||
```
|
||||
|
||||
### Batch Updates
|
||||
|
||||
```json
|
||||
// ONE call with multiple operations
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf-123",
|
||||
operations: [
|
||||
{type: "updateNode", nodeId: "slack-1", changes: {position: [100, 200]}},
|
||||
{type: "updateNode", nodeId: "http-1", changes: {position: [300, 200]}},
|
||||
{type: "cleanStaleConnections"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
### Core Behavior
|
||||
1. **Silent execution** - No commentary between tools
|
||||
2. **Parallel by default** - Execute independent operations simultaneously
|
||||
3. **Templates first** - Always check before building (2,709 available)
|
||||
4. **Multi-level validation** - Quick check → Full validation → Workflow validation
|
||||
5. **Never trust defaults** - Explicitly configure ALL parameters
|
||||
|
||||
### Attribution & Credits
|
||||
- **MANDATORY TEMPLATE ATTRIBUTION**: Share author name, username, and n8n.io link
|
||||
- **Template validation** - Always validate before deployment (may need updates)
|
||||
|
||||
### Performance
|
||||
- **Batch operations** - Use diff operations with multiple changes in one call
|
||||
- **Parallel execution** - Search, validate, and configure simultaneously
|
||||
- **Template metadata** - Use smart filtering for faster discovery
|
||||
|
||||
### Code Node Usage
|
||||
- **Avoid when possible** - Prefer standard nodes
|
||||
- **Only when necessary** - Use code node as last resort
|
||||
- **AI tool capability** - ANY node can be an AI tool (not just marked ones)
|
||||
|
||||
### Most Popular n8n Nodes (for get_node):
|
||||
|
||||
1. **n8n-nodes-base.code** - JavaScript/Python scripting
|
||||
2. **n8n-nodes-base.httpRequest** - HTTP API calls
|
||||
3. **n8n-nodes-base.webhook** - Event-driven triggers
|
||||
4. **n8n-nodes-base.set** - Data transformation
|
||||
5. **n8n-nodes-base.if** - Conditional routing
|
||||
6. **n8n-nodes-base.manualTrigger** - Manual workflow execution
|
||||
7. **n8n-nodes-base.respondToWebhook** - Webhook responses
|
||||
8. **n8n-nodes-base.scheduleTrigger** - Time-based triggers
|
||||
9. **@n8n/n8n-nodes-langchain.agent** - AI agents
|
||||
10. **n8n-nodes-base.googleSheets** - Spreadsheet integration
|
||||
11. **n8n-nodes-base.merge** - Data merging
|
||||
12. **n8n-nodes-base.switch** - Multi-branch routing
|
||||
13. **n8n-nodes-base.telegram** - Telegram bot integration
|
||||
14. **@n8n/n8n-nodes-langchain.lmChatOpenAi** - OpenAI chat models
|
||||
15. **n8n-nodes-base.splitInBatches** - Batch processing
|
||||
16. **n8n-nodes-base.openAi** - OpenAI legacy node
|
||||
17. **n8n-nodes-base.gmail** - Email automation
|
||||
18. **n8n-nodes-base.function** - Custom functions
|
||||
19. **n8n-nodes-base.stickyNote** - Workflow documentation
|
||||
20. **n8n-nodes-base.executeWorkflowTrigger** - Sub-workflow calls
|
||||
|
||||
**Note:** LangChain nodes use the `@n8n/n8n-nodes-langchain.` prefix, core nodes use `n8n-nodes-base.`
|
||||
|
||||
````
|
||||
|
||||
This helps the agent produce higher-quality, well-structured n8n workflows.
|
||||
|
||||
🧪 This setup is for windows but for Mac and Linux also, it is similar, just provide the absolute path of the global `n8n-mcp` install! 😄 Stay tuned for updates!
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.26.0",
|
||||
"version": "2.27.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.26.0",
|
||||
"version": "2.27.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@modelcontextprotocol/sdk": "1.20.1",
|
||||
"@n8n/n8n-nodes-langchain": "^1.120.1",
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"dotenv": "^16.5.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "3.24.1"
|
||||
},
|
||||
"bin": {
|
||||
"n8n-mcp": "dist/mcp/index.js"
|
||||
@@ -8454,6 +8454,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/community/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "0.3.68",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.68.tgz",
|
||||
@@ -8477,6 +8486,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/google-common": {
|
||||
"version": "0.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/google-common/-/google-common-0.2.18.tgz",
|
||||
@@ -8742,6 +8760,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/openai/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/pinecone": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/pinecone/-/pinecone-0.2.0.tgz",
|
||||
@@ -22681,6 +22708,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/langchain/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
"version": "0.3.69",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.69.tgz",
|
||||
@@ -33907,9 +33943,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"version": "3.24.1",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
|
||||
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.26.2",
|
||||
"version": "2.28.0",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -140,7 +140,7 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@modelcontextprotocol/sdk": "1.20.1",
|
||||
"@n8n/n8n-nodes-langchain": "^1.120.1",
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"dotenv": "^16.5.0",
|
||||
@@ -154,7 +154,7 @@
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "3.24.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-darwin-arm64": "^4.50.0",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.23.0",
|
||||
"version": "2.28.0",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.2",
|
||||
"@modelcontextprotocol/sdk": "1.20.1",
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ExecutionFilterOptions,
|
||||
ExecutionMode
|
||||
} from '../types/n8n-api';
|
||||
import type { TriggerType, TestWorkflowInput } from '../triggers/types';
|
||||
import {
|
||||
validateWorkflowStructure,
|
||||
hasWebhookTrigger,
|
||||
@@ -34,6 +35,7 @@ import { ExpressionFormatValidator, ExpressionFormatIssue } from '../services/ex
|
||||
import { WorkflowVersioningService } from '../services/workflow-versioning-service';
|
||||
import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
||||
import { telemetry } from '../telemetry';
|
||||
import { TemplateService } from '../templates/template-service';
|
||||
import {
|
||||
createCacheKey,
|
||||
createInstanceCache,
|
||||
@@ -84,6 +86,31 @@ interface CloudPlatformGuide {
|
||||
troubleshooting: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied Fix from Auto-Fix Operation
|
||||
*/
|
||||
interface AppliedFix {
|
||||
node: string;
|
||||
field: string;
|
||||
type: string;
|
||||
before: string;
|
||||
after: string;
|
||||
confidence: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-Fix Result Data from handleAutofixWorkflow
|
||||
*/
|
||||
interface AutofixResultData {
|
||||
fixesApplied?: number;
|
||||
fixes?: AppliedFix[];
|
||||
workflowId?: string;
|
||||
workflowName?: string;
|
||||
message?: string;
|
||||
summary?: string;
|
||||
stats?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow Validation Response Data
|
||||
*/
|
||||
@@ -395,17 +422,25 @@ const autofixWorkflowSchema = z.object({
|
||||
'typeversion-correction',
|
||||
'error-output-config',
|
||||
'node-type-correction',
|
||||
'webhook-missing-path'
|
||||
'webhook-missing-path',
|
||||
'typeversion-upgrade',
|
||||
'version-migration'
|
||||
])).optional(),
|
||||
confidenceThreshold: z.enum(['high', 'medium', 'low']).optional().default('medium'),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -1207,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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1788,7 +1909,7 @@ export async function handleDiagnostic(request: any, context?: InstanceContext):
|
||||
|
||||
// Check which tools are available
|
||||
const documentationTools = 7; // Base documentation tools (after v2.26.0 consolidation)
|
||||
const managementTools = apiConfigured ? 12 : 0; // Management tools requiring API (after v2.26.0 consolidation)
|
||||
const managementTools = apiConfigured ? 13 : 0; // Management tools requiring API (includes n8n_deploy_template)
|
||||
const totalTools = documentationTools + managementTools;
|
||||
|
||||
// Check npm version
|
||||
@@ -2189,3 +2310,316 @@ export async function handleWorkflowVersions(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Template Deployment Handler
|
||||
// ========================================================================
|
||||
|
||||
const deployTemplateSchema = z.object({
|
||||
templateId: z.number().positive().int(),
|
||||
name: z.string().optional(),
|
||||
autoUpgradeVersions: z.boolean().default(true),
|
||||
autoFix: z.boolean().default(true), // Auto-apply fixes after deployment
|
||||
stripCredentials: z.boolean().default(true)
|
||||
});
|
||||
|
||||
interface RequiredCredential {
|
||||
nodeType: string;
|
||||
nodeName: string;
|
||||
credentialType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy a workflow template from n8n.io directly to the user's n8n instance.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Fetches the template from the local template database
|
||||
* 2. Extracts credential requirements for user guidance
|
||||
* 3. Optionally strips credentials (for user to configure in n8n UI)
|
||||
* 4. Optionally upgrades node typeVersions to latest supported
|
||||
* 5. Optionally validates the workflow structure
|
||||
* 6. Creates the workflow in the n8n instance
|
||||
*/
|
||||
export async function handleDeployTemplate(
|
||||
args: unknown,
|
||||
templateService: TemplateService,
|
||||
repository: NodeRepository,
|
||||
context?: InstanceContext
|
||||
): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = deployTemplateSchema.parse(args);
|
||||
|
||||
// Fetch template
|
||||
const template = await templateService.getTemplate(input.templateId, 'full');
|
||||
if (!template) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Template ${input.templateId} not found`,
|
||||
details: {
|
||||
hint: 'Use search_templates to find available templates',
|
||||
templateUrl: `https://n8n.io/workflows/${input.templateId}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Extract workflow from template (deep copy to avoid mutation)
|
||||
const workflow = JSON.parse(JSON.stringify(template.workflow));
|
||||
if (!workflow || !workflow.nodes) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Template has invalid workflow structure',
|
||||
details: { templateId: input.templateId }
|
||||
};
|
||||
}
|
||||
|
||||
// Set workflow name
|
||||
const workflowName = input.name || template.name;
|
||||
|
||||
// Collect required credentials before stripping
|
||||
const requiredCredentials: RequiredCredential[] = [];
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.credentials && typeof node.credentials === 'object') {
|
||||
for (const [credType] of Object.entries(node.credentials)) {
|
||||
requiredCredentials.push({
|
||||
nodeType: node.type,
|
||||
nodeName: node.name,
|
||||
credentialType: credType
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip credentials if requested
|
||||
if (input.stripCredentials) {
|
||||
workflow.nodes = workflow.nodes.map((node: any) => {
|
||||
const { credentials, ...rest } = node;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-upgrade typeVersions if requested
|
||||
if (input.autoUpgradeVersions) {
|
||||
const autoFixer = new WorkflowAutoFixer(repository);
|
||||
|
||||
// Run validation to get issues to fix
|
||||
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
|
||||
const validationResult = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
validateConnections: false,
|
||||
validateExpressions: false,
|
||||
profile: 'runtime'
|
||||
});
|
||||
|
||||
// Generate fixes focused on typeVersion upgrades
|
||||
const fixResult = await autoFixer.generateFixes(
|
||||
workflow,
|
||||
validationResult,
|
||||
[],
|
||||
{ fixTypes: ['typeversion-upgrade', 'typeversion-correction'] }
|
||||
);
|
||||
|
||||
// Apply fixes to workflow
|
||||
if (fixResult.operations.length > 0) {
|
||||
for (const op of fixResult.operations) {
|
||||
if (op.type === 'updateNode' && op.updates) {
|
||||
const node = workflow.nodes.find((n: any) =>
|
||||
n.id === op.nodeId || n.name === op.nodeName
|
||||
);
|
||||
if (node) {
|
||||
for (const [path, value] of Object.entries(op.updates)) {
|
||||
if (path === 'typeVersion') {
|
||||
node.typeVersion = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identify trigger type
|
||||
const triggerNode = workflow.nodes.find((n: any) =>
|
||||
n.type?.includes('Trigger') ||
|
||||
n.type?.includes('webhook') ||
|
||||
n.type === 'n8n-nodes-base.webhook'
|
||||
);
|
||||
const triggerType = triggerNode?.type?.split('.').pop() || 'manual';
|
||||
|
||||
// Create workflow via API (always creates inactive)
|
||||
// Deploy first, then fix - this ensures the workflow exists before we modify it
|
||||
const createdWorkflow = await client.createWorkflow({
|
||||
name: workflowName,
|
||||
nodes: workflow.nodes,
|
||||
connections: workflow.connections,
|
||||
settings: workflow.settings || { executionOrder: 'v1' }
|
||||
});
|
||||
|
||||
// Get base URL for workflow link
|
||||
const apiConfig = context ? getN8nApiConfigFromContext(context) : getN8nApiConfig();
|
||||
const baseUrl = apiConfig?.baseUrl?.replace('/api/v1', '') || '';
|
||||
|
||||
// Auto-fix common issues after deployment (expression format, etc.)
|
||||
let fixesApplied: AppliedFix[] = [];
|
||||
let fixSummary = '';
|
||||
let autoFixStatus: 'success' | 'failed' | 'skipped' = 'skipped';
|
||||
|
||||
if (input.autoFix) {
|
||||
try {
|
||||
// Run autofix on the deployed workflow
|
||||
const autofixResult = await handleAutofixWorkflow(
|
||||
{
|
||||
id: createdWorkflow.id,
|
||||
applyFixes: true,
|
||||
fixTypes: ['expression-format', 'typeversion-upgrade'],
|
||||
confidenceThreshold: 'medium'
|
||||
},
|
||||
repository,
|
||||
context
|
||||
);
|
||||
|
||||
if (autofixResult.success && autofixResult.data) {
|
||||
const fixData = autofixResult.data as AutofixResultData;
|
||||
autoFixStatus = 'success';
|
||||
if (fixData.fixesApplied && fixData.fixesApplied > 0) {
|
||||
fixesApplied = fixData.fixes || [];
|
||||
fixSummary = ` Auto-fixed ${fixData.fixesApplied} issue(s).`;
|
||||
}
|
||||
}
|
||||
} catch (fixError) {
|
||||
// Log but don't fail - autofix is best-effort
|
||||
autoFixStatus = 'failed';
|
||||
logger.warn('Auto-fix failed after template deployment', {
|
||||
workflowId: createdWorkflow.id,
|
||||
error: fixError instanceof Error ? fixError.message : 'Unknown error'
|
||||
});
|
||||
fixSummary = ' Auto-fix failed (workflow deployed successfully).';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
workflowId: createdWorkflow.id,
|
||||
name: createdWorkflow.name,
|
||||
active: false,
|
||||
nodeCount: workflow.nodes.length,
|
||||
triggerType,
|
||||
requiredCredentials: requiredCredentials.length > 0 ? requiredCredentials : undefined,
|
||||
url: baseUrl ? `${baseUrl}/workflow/${createdWorkflow.id}` : undefined,
|
||||
templateId: input.templateId,
|
||||
templateUrl: template.url || `https://n8n.io/workflows/${input.templateId}`,
|
||||
autoFixStatus,
|
||||
fixesApplied: fixesApplied.length > 0 ? fixesApplied : undefined
|
||||
},
|
||||
message: `Workflow "${createdWorkflow.name}" deployed successfully from template ${input.templateId}.${fixSummary} ${
|
||||
requiredCredentials.length > 0
|
||||
? `Configure ${requiredCredentials.length} credential(s) in n8n to activate.`
|
||||
: ''
|
||||
}`
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid input',
|
||||
details: { errors: error.errors }
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof N8nApiError) {
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,6 +856,12 @@ export class N8NDocumentationMCPServer {
|
||||
? { valid: true, errors: [] }
|
||||
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
|
||||
break;
|
||||
case 'n8n_deploy_template':
|
||||
// Requires templateId parameter
|
||||
validationResult = args.templateId !== undefined
|
||||
? { valid: true, errors: [] }
|
||||
: { valid: false, errors: [{ field: 'templateId', message: 'templateId is required' }] };
|
||||
break;
|
||||
default:
|
||||
// For tools not yet migrated to schema validation, use basic validation
|
||||
return this.validateToolParamsBasic(toolName, args, legacyRequiredParams || []);
|
||||
@@ -1170,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;
|
||||
@@ -1203,6 +1209,13 @@ export class N8NDocumentationMCPServer {
|
||||
this.validateToolParams(name, args, ['mode']);
|
||||
return n8nHandlers.handleWorkflowVersions(args, this.repository!, this.instanceContext);
|
||||
|
||||
case 'n8n_deploy_template':
|
||||
this.validateToolParams(name, args, ['templateId']);
|
||||
await this.ensureInitialized();
|
||||
if (!this.templateService) throw new Error('Template service not initialized');
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
return n8nHandlers.handleDeployTemplate(args, this.templateService, this.repository, this.instanceContext);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ export const getNodeDoc: ToolDocumentation = {
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Unified tool for all node information needs. Replaces get_node_info, get_node_essentials, get_node_documentation, and search_node_properties with a single versatile API.
|
||||
|
||||
**Detail Levels (mode="info", default):**
|
||||
description: `**Detail Levels (mode="info", default):**
|
||||
- minimal (~200 tokens): Basic metadata only - nodeType, displayName, description, category
|
||||
- standard (~1-2K tokens): Essential properties + operations - recommended for most tasks
|
||||
- full (~3-8K tokens): Complete node schema - use only when standard insufficient
|
||||
|
||||
@@ -4,7 +4,7 @@ export const searchNodesDoc: ToolDocumentation = {
|
||||
name: 'search_nodes',
|
||||
category: 'discovery',
|
||||
essentials: {
|
||||
description: 'Text search across node names and descriptions. Returns most relevant nodes first, with frequently-used nodes (HTTP Request, Webhook, Set, Code, Slack) prioritized in results. Searches all 525 nodes in the database.',
|
||||
description: 'Text search across node names and descriptions. Returns most relevant nodes first, with frequently-used nodes (HTTP Request, Webhook, Set, Code, Slack) prioritized in results. Searches all 500+ nodes in the database.',
|
||||
keyParameters: ['query', 'mode', 'limit'],
|
||||
example: 'search_nodes({query: "webhook"})',
|
||||
performance: '<20ms even for complex queries',
|
||||
@@ -42,7 +42,7 @@ export const searchNodesDoc: ToolDocumentation = {
|
||||
'Start with single keywords for broadest results',
|
||||
'Use FUZZY mode when users might misspell node names',
|
||||
'AND mode works best for 2-3 word searches',
|
||||
'Combine with get_node_essentials after finding the right node'
|
||||
'Combine with get_node after finding the right node'
|
||||
],
|
||||
pitfalls: [
|
||||
'AND mode searches all fields (name, description) not just node names',
|
||||
|
||||
@@ -7,9 +7,7 @@ import { validateNodeDoc, validateWorkflowDoc } from './validation';
|
||||
import { getTemplateDoc, searchTemplatesDoc } from './templates';
|
||||
import {
|
||||
toolsDocumentationDoc,
|
||||
n8nDiagnosticDoc,
|
||||
n8nHealthCheckDoc,
|
||||
n8nListAvailableToolsDoc
|
||||
n8nHealthCheckDoc
|
||||
} from './system';
|
||||
import { aiAgentsGuide } from './guides';
|
||||
import {
|
||||
@@ -21,18 +19,17 @@ import {
|
||||
n8nListWorkflowsDoc,
|
||||
n8nValidateWorkflowDoc,
|
||||
n8nAutofixWorkflowDoc,
|
||||
n8nTriggerWebhookWorkflowDoc,
|
||||
n8nExecutionsDoc
|
||||
n8nTestWorkflowDoc,
|
||||
n8nExecutionsDoc,
|
||||
n8nWorkflowVersionsDoc,
|
||||
n8nDeployTemplateDoc
|
||||
} from './workflow_management';
|
||||
|
||||
// Combine all tool documentations into a single object
|
||||
// Total: 19 tools after v2.26.0 consolidation
|
||||
export const toolsDocumentation: Record<string, ToolDocumentation> = {
|
||||
// System tools
|
||||
tools_documentation: toolsDocumentationDoc,
|
||||
n8n_diagnostic: n8nDiagnosticDoc,
|
||||
n8n_health_check: n8nHealthCheckDoc,
|
||||
n8n_list_available_tools: n8nListAvailableToolsDoc,
|
||||
|
||||
// Guides
|
||||
ai_agents_guide: aiAgentsGuide,
|
||||
@@ -40,28 +37,30 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
|
||||
// Discovery tools
|
||||
search_nodes: searchNodesDoc,
|
||||
|
||||
// Configuration tools (consolidated)
|
||||
get_node: getNodeDoc, // Replaces: get_node_info, get_node_essentials, get_node_documentation, search_node_properties
|
||||
// Configuration tools
|
||||
get_node: getNodeDoc,
|
||||
|
||||
// Validation tools (consolidated)
|
||||
validate_node: validateNodeDoc, // Replaces: validate_node_operation, validate_node_minimal
|
||||
validate_workflow: validateWorkflowDoc, // Options replace: validate_workflow_connections, validate_workflow_expressions
|
||||
// Validation tools
|
||||
validate_node: validateNodeDoc,
|
||||
validate_workflow: validateWorkflowDoc,
|
||||
|
||||
// Template tools (consolidated)
|
||||
// Template tools
|
||||
get_template: getTemplateDoc,
|
||||
search_templates: searchTemplatesDoc, // Modes replace: list_node_templates, search_templates_by_metadata, get_templates_for_task
|
||||
search_templates: searchTemplatesDoc,
|
||||
|
||||
// Workflow Management tools (n8n API)
|
||||
n8n_create_workflow: n8nCreateWorkflowDoc,
|
||||
n8n_get_workflow: n8nGetWorkflowDoc, // Modes replace: n8n_get_workflow_details, n8n_get_workflow_structure, n8n_get_workflow_minimal
|
||||
n8n_get_workflow: n8nGetWorkflowDoc,
|
||||
n8n_update_full_workflow: n8nUpdateFullWorkflowDoc,
|
||||
n8n_update_partial_workflow: n8nUpdatePartialWorkflowDoc,
|
||||
n8n_delete_workflow: n8nDeleteWorkflowDoc,
|
||||
n8n_list_workflows: n8nListWorkflowsDoc,
|
||||
n8n_validate_workflow: n8nValidateWorkflowDoc,
|
||||
n8n_autofix_workflow: n8nAutofixWorkflowDoc,
|
||||
n8n_trigger_webhook_workflow: n8nTriggerWebhookWorkflowDoc,
|
||||
n8n_executions: n8nExecutionsDoc // Actions replace: n8n_get_execution, n8n_list_executions, n8n_delete_execution
|
||||
n8n_test_workflow: n8nTestWorkflowDoc,
|
||||
n8n_executions: n8nExecutionsDoc,
|
||||
n8n_workflow_versions: n8nWorkflowVersionsDoc,
|
||||
n8n_deploy_template: n8nDeployTemplateDoc
|
||||
};
|
||||
|
||||
// Re-export types
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export { toolsDocumentationDoc } from './tools-documentation';
|
||||
export { n8nDiagnosticDoc } from './n8n-diagnostic';
|
||||
export { n8nHealthCheckDoc } from './n8n-health-check';
|
||||
export { n8nListAvailableToolsDoc } from './n8n-list-available-tools';
|
||||
export { n8nHealthCheckDoc } from './n8n-health-check';
|
||||
@@ -5,8 +5,8 @@ export const n8nHealthCheckDoc: ToolDocumentation = {
|
||||
category: 'system',
|
||||
essentials: {
|
||||
description: 'Check n8n instance health, API connectivity, version status, and performance metrics',
|
||||
keyParameters: [],
|
||||
example: 'n8n_health_check({})',
|
||||
keyParameters: ['mode', 'verbose'],
|
||||
example: 'n8n_health_check({mode: "status"})',
|
||||
performance: 'Fast - single API call (~150-200ms median)',
|
||||
tips: [
|
||||
'Use before starting workflow operations to ensure n8n is responsive',
|
||||
@@ -31,7 +31,21 @@ Health checks are crucial for:
|
||||
- Detecting performance degradation
|
||||
- Verifying API compatibility before operations
|
||||
- Ensuring authentication is working correctly`,
|
||||
parameters: {},
|
||||
parameters: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Operation mode: "status" (default) for quick health check, "diagnostic" for detailed debug info including env vars and tool status',
|
||||
default: 'status',
|
||||
enum: ['status', 'diagnostic']
|
||||
},
|
||||
verbose: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Include extra details in diagnostic mode',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
returns: `Health status object containing:
|
||||
- status: Overall health status ('healthy', 'degraded', 'error')
|
||||
- n8nVersion: n8n instance version information
|
||||
@@ -81,6 +95,6 @@ Health checks are crucial for:
|
||||
'Does not check individual workflow health',
|
||||
'Health endpoint might be cached - not real-time for all metrics'
|
||||
],
|
||||
relatedTools: ['n8n_diagnostic', 'n8n_list_available_tools', 'n8n_list_workflows']
|
||||
relatedTools: ['n8n_list_workflows', 'n8n_validate_workflow', 'n8n_workflow_versions']
|
||||
}
|
||||
};
|
||||
@@ -4,23 +4,30 @@ export const getTemplateDoc: ToolDocumentation = {
|
||||
name: 'get_template',
|
||||
category: 'templates',
|
||||
essentials: {
|
||||
description: 'Get complete workflow JSON by ID. Ready to import. IDs from search_templates.',
|
||||
keyParameters: ['templateId'],
|
||||
example: 'get_template({templateId: 1234})',
|
||||
description: 'Get workflow template by ID with configurable detail level. Ready to import. IDs from search_templates.',
|
||||
keyParameters: ['templateId', 'mode'],
|
||||
example: 'get_template({templateId: 1234, mode: "full"})',
|
||||
performance: 'Fast (<100ms) - single database lookup',
|
||||
tips: [
|
||||
'Get template IDs from search_templates first',
|
||||
'Returns complete workflow JSON ready for import into n8n',
|
||||
'Includes all nodes, connections, and settings'
|
||||
'Use mode="nodes_only" for quick overview, "structure" for topology, "full" for import',
|
||||
'Returns complete workflow JSON ready for import into n8n'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Retrieves the complete workflow JSON for a specific template by its ID. The returned workflow can be directly imported into n8n through the UI or API. This tool fetches pre-built workflows from the community template library containing 399+ curated workflows.`,
|
||||
description: `Retrieves the complete workflow JSON for a specific template by its ID. The returned workflow can be directly imported into n8n through the UI or API. This tool fetches pre-built workflows from the community template library containing 2,700+ curated workflows.`,
|
||||
parameters: {
|
||||
templateId: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
description: 'The numeric ID of the template to retrieve. Get IDs from search_templates'
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Response detail level: "nodes_only" (minimal - just node list), "structure" (nodes + connections), "full" (complete workflow JSON, default)',
|
||||
default: 'full',
|
||||
enum: ['nodes_only', 'structure', 'full']
|
||||
}
|
||||
},
|
||||
returns: `Returns an object containing:
|
||||
@@ -39,9 +46,10 @@ export const getTemplateDoc: ToolDocumentation = {
|
||||
- settings: Workflow configuration (timezone, error handling, etc.)
|
||||
- usage: Instructions for using the workflow`,
|
||||
examples: [
|
||||
'get_template({templateId: 1234}) - Get Slack notification workflow',
|
||||
'get_template({templateId: 5678}) - Get data sync workflow',
|
||||
'get_template({templateId: 9012}) - Get AI chatbot workflow'
|
||||
'get_template({templateId: 1234}) - Get complete workflow (default mode="full")',
|
||||
'get_template({templateId: 1234, mode: "nodes_only"}) - Get just the node list',
|
||||
'get_template({templateId: 1234, mode: "structure"}) - Get nodes and connections',
|
||||
'get_template({templateId: 5678, mode: "full"}) - Get complete workflow JSON for import'
|
||||
],
|
||||
useCases: [
|
||||
'Download workflows for direct import into n8n',
|
||||
|
||||
@@ -16,9 +16,7 @@ export const searchTemplatesDoc: ToolDocumentation = {
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Unified template search tool with four search modes. Replaces search_templates, list_node_templates, search_templates_by_metadata, and get_templates_for_task.
|
||||
|
||||
**Search Modes:**
|
||||
description: `**Search Modes:**
|
||||
- keyword (default): Full-text search across template names and descriptions
|
||||
- by_nodes: Find templates that use specific node types
|
||||
- by_task: Get curated templates for predefined task categories
|
||||
|
||||
@@ -16,9 +16,7 @@ export const validateNodeDoc: ToolDocumentation = {
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Unified node configuration validator. Replaces validate_node_operation and validate_node_minimal with a single tool.
|
||||
|
||||
**Validation Modes:**
|
||||
description: `**Validation Modes:**
|
||||
- full (default): Comprehensive validation with errors, warnings, suggestions, and automatic structure validation
|
||||
- minimal: Quick check for required fields only - fast but less thorough
|
||||
|
||||
|
||||
@@ -6,5 +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';
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
};
|
||||
@@ -11,7 +11,7 @@ export const n8nDeleteWorkflowDoc: ToolDocumentation = {
|
||||
tips: [
|
||||
'Action is irreversible',
|
||||
'Deletes all execution history',
|
||||
'Check workflow first with get_minimal'
|
||||
'Check workflow first with n8n_get_workflow({mode: "minimal"})'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
@@ -34,7 +34,7 @@ export const n8nDeleteWorkflowDoc: ToolDocumentation = {
|
||||
performance: 'Fast operation - typically 50-150ms. May take longer if workflow has extensive execution history.',
|
||||
bestPractices: [
|
||||
'Always confirm before deletion',
|
||||
'Check workflow with get_minimal first',
|
||||
'Check workflow with n8n_get_workflow({mode: "minimal"}) first',
|
||||
'Consider deactivating instead of deleting',
|
||||
'Export workflow before deletion for backup'
|
||||
],
|
||||
|
||||
71
src/mcp/tool-docs/workflow_management/n8n-deploy-template.ts
Normal file
71
src/mcp/tool-docs/workflow_management/n8n-deploy-template.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const n8nDeployTemplateDoc: ToolDocumentation = {
|
||||
name: 'n8n_deploy_template',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Deploy a workflow template from n8n.io directly to your n8n instance. Deploys first, then auto-fixes common issues (expression format, typeVersions).',
|
||||
keyParameters: ['templateId', 'name', 'autoUpgradeVersions', 'autoFix', 'stripCredentials'],
|
||||
example: 'n8n_deploy_template({templateId: 2776, name: "My Deployed Template"})',
|
||||
performance: 'Network-dependent',
|
||||
tips: [
|
||||
'Auto-fixes expression format issues after deployment',
|
||||
'Workflow created inactive - configure credentials in n8n UI first',
|
||||
'Returns list of required credentials and fixes applied',
|
||||
'Use search_templates to find template IDs'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: 'Deploys a workflow template from n8n.io directly to your n8n instance. This tool deploys first, then automatically fixes common issues like missing expression prefixes (=) and outdated typeVersions. Templates are stored locally and fetched from the database. The workflow is always created in an inactive state, allowing you to configure credentials before activation.',
|
||||
parameters: {
|
||||
templateId: { type: 'number', required: true, description: 'Template ID from n8n.io (find via search_templates)' },
|
||||
name: { type: 'string', description: 'Custom workflow name (default: template name)' },
|
||||
autoUpgradeVersions: { type: 'boolean', description: 'Upgrade node typeVersions to latest supported (default: true)' },
|
||||
autoFix: { type: 'boolean', description: 'Auto-apply fixes after deployment for expression format issues, missing = prefix, etc. (default: true)' },
|
||||
stripCredentials: { type: 'boolean', description: 'Remove credential references - user configures in n8n UI (default: true)' }
|
||||
},
|
||||
returns: 'Object with workflowId, name, nodeCount, triggerType, requiredCredentials array, url, templateId, templateUrl, autoFixStatus (success/failed/skipped), and fixesApplied array',
|
||||
examples: [
|
||||
`// Deploy template with default settings (auto-fix enabled)
|
||||
n8n_deploy_template({templateId: 2776})`,
|
||||
`// Deploy with custom name
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
name: "My Google Drive to Airtable Sync"
|
||||
})`,
|
||||
`// Deploy without auto-fix (not recommended)
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
autoFix: false
|
||||
})`,
|
||||
`// Keep original node versions (useful for compatibility)
|
||||
n8n_deploy_template({
|
||||
templateId: 2776,
|
||||
autoUpgradeVersions: false
|
||||
})`
|
||||
],
|
||||
useCases: [
|
||||
'Quickly deploy pre-built workflow templates',
|
||||
'Set up common automation patterns',
|
||||
'Bootstrap new projects with proven workflows',
|
||||
'Deploy templates found via search_templates'
|
||||
],
|
||||
performance: 'Network-dependent - Typically 300-800ms (template fetch + workflow creation + autofix)',
|
||||
bestPractices: [
|
||||
'Use search_templates to find templates by use case',
|
||||
'Review required credentials in the response',
|
||||
'Check autoFixStatus in response - "success", "failed", or "skipped"',
|
||||
'Check fixesApplied in response to see what was automatically corrected',
|
||||
'Configure credentials in n8n UI before activating',
|
||||
'Test workflow before connecting to production systems'
|
||||
],
|
||||
pitfalls: [
|
||||
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - tool unavailable without n8n API access',
|
||||
'Workflows created in INACTIVE state - must configure credentials and activate in n8n',
|
||||
'Templates may reference services you do not have (Slack, Google, etc.)',
|
||||
'Template database must be populated - run npm run fetch:templates if templates not found',
|
||||
'Some issues may not be auto-fixable (e.g., missing required fields that need user input)'
|
||||
],
|
||||
relatedTools: ['search_templates', 'get_template', 'n8n_create_workflow', 'n8n_autofix_workflow']
|
||||
}
|
||||
};
|
||||
@@ -16,9 +16,7 @@ export const n8nExecutionsDoc: ToolDocumentation = {
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Unified execution management tool. Replaces n8n_get_execution, n8n_list_executions, and n8n_delete_execution.
|
||||
|
||||
**Actions:**
|
||||
description: `**Actions:**
|
||||
- get: Retrieve execution details by ID with configurable detail level
|
||||
- list: List executions with filtering and pagination
|
||||
- delete: Remove an execution record from history
|
||||
@@ -79,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']
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,9 +16,7 @@ export const n8nGetWorkflowDoc: ToolDocumentation = {
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Unified workflow retrieval with configurable detail levels. Replaces n8n_get_workflow, n8n_get_workflow_details, n8n_get_workflow_structure, and n8n_get_workflow_minimal.
|
||||
|
||||
**Modes:**
|
||||
description: `**Modes:**
|
||||
- full (default): Complete workflow including all nodes with parameters, connections, and settings
|
||||
- details: Full workflow plus execution statistics (success/error counts, last execution time)
|
||||
- structure: Nodes and connections only - useful for topology analysis
|
||||
|
||||
138
src/mcp/tool-docs/workflow_management/n8n-test-workflow.ts
Normal file
138
src/mcp/tool-docs/workflow_management/n8n-test-workflow.ts
Normal 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']
|
||||
}
|
||||
};
|
||||
@@ -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']
|
||||
}
|
||||
};
|
||||
@@ -90,7 +90,6 @@ Full support for all 8 AI connection types used in n8n AI workflows:
|
||||
|
||||
**Important Notes**:
|
||||
- **AI nodes do NOT require main connections**: Nodes like OpenAI Chat Model, Postgres Chat Memory, Embeddings OpenAI, and Supabase Vector Store use AI-specific connection types exclusively. They should ONLY have connections like \`ai_languageModel\`, \`ai_memory\`, \`ai_embedding\`, or \`ai_tool\` - NOT \`main\` connections.
|
||||
- **Fixed in v2.21.1**: Validation now correctly recognizes AI nodes that only have AI-specific connections without requiring \`main\` connections (resolves issue #357).
|
||||
|
||||
**Best Practices**:
|
||||
- Always specify \`sourceOutput\` for AI connections (defaults to "main" if omitted)
|
||||
|
||||
168
src/mcp/tool-docs/workflow_management/n8n-workflow-versions.ts
Normal file
168
src/mcp/tool-docs/workflow_management/n8n-workflow-versions.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const n8nWorkflowVersionsDoc: ToolDocumentation = {
|
||||
name: 'n8n_workflow_versions',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Manage workflow version history, rollback to previous versions, and cleanup old versions',
|
||||
keyParameters: ['mode', 'workflowId', 'versionId'],
|
||||
example: 'n8n_workflow_versions({mode: "list", workflowId: "abc123"})',
|
||||
performance: 'Fast for list/get (~100ms), moderate for rollback (~200-500ms)',
|
||||
tips: [
|
||||
'Use mode="list" to see all saved versions before rollback',
|
||||
'Rollback creates a backup version automatically',
|
||||
'Use prune to clean up old versions and save storage',
|
||||
'truncate requires explicit confirmTruncate: true'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Comprehensive workflow version management system. Supports six operations:
|
||||
|
||||
**list** - Show version history for a workflow
|
||||
- Returns all saved versions with timestamps, snapshot sizes, and metadata
|
||||
- Use limit parameter to control how many versions to return
|
||||
|
||||
**get** - Get details of a specific version
|
||||
- Returns the complete workflow snapshot from that version
|
||||
- Use to compare versions or extract old configurations
|
||||
|
||||
**rollback** - Restore workflow to a previous version
|
||||
- Creates a backup of the current workflow before rollback
|
||||
- Optionally validates the workflow structure before applying
|
||||
- Returns the restored workflow and backup version ID
|
||||
|
||||
**delete** - Delete specific version(s)
|
||||
- Delete a single version by versionId
|
||||
- Delete all versions for a workflow with deleteAll: true
|
||||
|
||||
**prune** - Clean up old versions
|
||||
- Keeps only the N most recent versions (default: 10)
|
||||
- Useful for managing storage and keeping history manageable
|
||||
|
||||
**truncate** - Delete ALL versions for ALL workflows
|
||||
- Dangerous operation requiring explicit confirmation
|
||||
- Use for complete version history cleanup`,
|
||||
parameters: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Operation mode: "list", "get", "rollback", "delete", "prune", or "truncate"',
|
||||
enum: ['list', 'get', 'rollback', 'delete', 'prune', 'truncate']
|
||||
},
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Workflow ID (required for list, rollback, delete, prune modes)'
|
||||
},
|
||||
versionId: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Version ID (required for get mode, optional for rollback to specific version, required for single delete)'
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 10,
|
||||
description: 'Maximum versions to return in list mode'
|
||||
},
|
||||
validateBefore: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
description: 'Validate workflow structure before rollback (rollback mode only)'
|
||||
},
|
||||
deleteAll: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
description: 'Delete all versions for workflow (delete mode only)'
|
||||
},
|
||||
maxVersions: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 10,
|
||||
description: 'Keep N most recent versions (prune mode only)'
|
||||
},
|
||||
confirmTruncate: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
description: 'REQUIRED: Must be true to truncate all versions (truncate mode only)'
|
||||
}
|
||||
},
|
||||
returns: `Response varies by mode:
|
||||
|
||||
**list mode:**
|
||||
- versions: Array of version objects with id, workflowId, snapshotSize, createdAt
|
||||
- totalCount: Total number of versions
|
||||
|
||||
**get mode:**
|
||||
- version: Complete version object including workflow snapshot
|
||||
|
||||
**rollback mode:**
|
||||
- success: Boolean indicating success
|
||||
- restoredVersion: The version that was restored
|
||||
- backupVersionId: ID of the backup created before rollback
|
||||
|
||||
**delete mode:**
|
||||
- deletedCount: Number of versions deleted
|
||||
|
||||
**prune mode:**
|
||||
- prunedCount: Number of old versions removed
|
||||
- remainingCount: Number of versions kept
|
||||
|
||||
**truncate mode:**
|
||||
- deletedCount: Total versions deleted across all workflows`,
|
||||
examples: [
|
||||
'// List version history\nn8n_workflow_versions({mode: "list", workflowId: "abc123", limit: 5})',
|
||||
'// Get specific version details\nn8n_workflow_versions({mode: "get", versionId: 42})',
|
||||
'// Rollback to latest saved version\nn8n_workflow_versions({mode: "rollback", workflowId: "abc123"})',
|
||||
'// Rollback to specific version\nn8n_workflow_versions({mode: "rollback", workflowId: "abc123", versionId: 42})',
|
||||
'// Delete specific version\nn8n_workflow_versions({mode: "delete", workflowId: "abc123", versionId: 42})',
|
||||
'// Delete all versions for workflow\nn8n_workflow_versions({mode: "delete", workflowId: "abc123", deleteAll: true})',
|
||||
'// Prune to keep only 5 most recent\nn8n_workflow_versions({mode: "prune", workflowId: "abc123", maxVersions: 5})',
|
||||
'// Truncate all versions (dangerous!)\nn8n_workflow_versions({mode: "truncate", confirmTruncate: true})'
|
||||
],
|
||||
useCases: [
|
||||
'Recover from accidental workflow changes',
|
||||
'Compare workflow versions to understand changes',
|
||||
'Maintain audit trail of workflow modifications',
|
||||
'Clean up old versions to save database storage',
|
||||
'Roll back failed workflow deployments'
|
||||
],
|
||||
performance: `Performance varies by operation:
|
||||
- list: Fast (~100ms) - simple database query
|
||||
- get: Fast (~100ms) - single row retrieval
|
||||
- rollback: Moderate (~200-500ms) - includes backup creation and workflow update
|
||||
- delete: Fast (~50-100ms) - database delete operation
|
||||
- prune: Moderate (~100-300ms) - depends on number of versions to delete
|
||||
- truncate: Slow (1-5s) - deletes all records across all workflows`,
|
||||
modeComparison: `| Mode | Required Params | Optional Params | Risk Level |
|
||||
|------|-----------------|-----------------|------------|
|
||||
| list | workflowId | limit | Low |
|
||||
| get | versionId | - | Low |
|
||||
| rollback | workflowId | versionId, validateBefore | Medium |
|
||||
| delete | workflowId | versionId, deleteAll | High |
|
||||
| prune | workflowId | maxVersions | Medium |
|
||||
| truncate | confirmTruncate=true | - | Critical |`,
|
||||
bestPractices: [
|
||||
'Always list versions before rollback to pick the right one',
|
||||
'Enable validateBefore for rollback to catch structural issues',
|
||||
'Use prune regularly to keep version history manageable',
|
||||
'Never use truncate in production without explicit need',
|
||||
'Document why you are rolling back for audit purposes'
|
||||
],
|
||||
pitfalls: [
|
||||
'Rollback overwrites current workflow - backup is created automatically',
|
||||
'Deleted versions cannot be recovered',
|
||||
'Truncate affects ALL workflows - use with extreme caution',
|
||||
'Version IDs are sequential but may have gaps after deletes',
|
||||
'Large workflows may have significant version storage overhead'
|
||||
],
|
||||
relatedTools: [
|
||||
'n8n_get_workflow - View current workflow state',
|
||||
'n8n_update_partial_workflow - Make incremental changes',
|
||||
'n8n_validate_workflow - Validate before deployment'
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -126,7 +126,7 @@ When working with Code nodes, always start by calling the relevant guide:
|
||||
- searchMode='by_task': Curated task-based templates
|
||||
- searchMode='by_metadata': Filter by complexity/services
|
||||
|
||||
**n8n API Tools** (12 tools, requires N8N_API_URL configuration)
|
||||
**n8n API Tools** (13 tools, requires N8N_API_URL configuration)
|
||||
- n8n_create_workflow - Create new workflows
|
||||
- n8n_get_workflow - Get workflow with mode='full'/'details'/'structure'/'minimal'
|
||||
- n8n_update_full_workflow - Full workflow replacement
|
||||
@@ -135,10 +135,11 @@ 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
|
||||
- n8n_deploy_template - Deploy templates directly to n8n instance
|
||||
|
||||
## Performance Characteristics
|
||||
- Instant (<10ms): search_nodes, get_node (minimal/standard)
|
||||
@@ -422,8 +423,8 @@ try {
|
||||
5. Use descriptive variable names
|
||||
|
||||
## Related Tools
|
||||
- get_node_essentials("nodes-base.code")
|
||||
- validate_node_operation()
|
||||
- get_node({nodeType: "nodes-base.code"}) - Get Code node configuration details
|
||||
- validate_node({nodeType: "nodes-base.code", config: {...}}) - Validate Code node setup
|
||||
- python_code_node_guide (for Python syntax)`;
|
||||
}
|
||||
|
||||
@@ -691,7 +692,7 @@ except json.JSONDecodeError:
|
||||
\`\`\`
|
||||
|
||||
## Related Tools
|
||||
- get_node_essentials("nodes-base.code")
|
||||
- validate_node_operation()
|
||||
- get_node({nodeType: "nodes-base.code"}) - Get Code node configuration details
|
||||
- validate_node({nodeType: "nodes-base.code", config: {...}}) - Validate Code node setup
|
||||
- javascript_code_node_guide (for JavaScript syntax)`;
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -445,5 +469,40 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
},
|
||||
required: ['mode']
|
||||
}
|
||||
},
|
||||
|
||||
// Template Deployment Tool
|
||||
{
|
||||
name: 'n8n_deploy_template',
|
||||
description: `Deploy a workflow template from n8n.io directly to your n8n instance. Deploys first, then auto-fixes common issues (expression format, typeVersions). Returns workflow ID, required credentials, and fixes applied.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
templateId: {
|
||||
type: 'number',
|
||||
description: 'Template ID from n8n.io (required)'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Custom workflow name (default: template name)'
|
||||
},
|
||||
autoUpgradeVersions: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Automatically upgrade node typeVersions to latest supported (default: true)'
|
||||
},
|
||||
autoFix: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Auto-apply fixes after deployment for expression format issues, missing = prefix, etc. (default: true)'
|
||||
},
|
||||
stripCredentials: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Remove credential references from nodes - user configures in n8n UI (default: true)'
|
||||
}
|
||||
},
|
||||
required: ['templateId']
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -97,12 +97,12 @@ export class ExpressionValidator {
|
||||
errors.push('Unmatched expression brackets {{ }}');
|
||||
}
|
||||
|
||||
// Check for nested expressions (not supported in n8n)
|
||||
if (expression.includes('{{') && expression.includes('{{', expression.indexOf('{{') + 2)) {
|
||||
const match = expression.match(/\{\{.*\{\{/);
|
||||
if (match) {
|
||||
errors.push('Nested expressions are not supported');
|
||||
}
|
||||
// Check for truly nested expressions (not supported in n8n)
|
||||
// This means {{ inside another {{ }}, like {{ {{ $json }} }}
|
||||
// NOT multiple expressions like {{ $json.a }} text {{ $json.b }} (which is valid)
|
||||
const nestedPattern = /\{\{[^}]*\{\{/;
|
||||
if (nestedPattern.test(expression)) {
|
||||
errors.push('Nested expressions are not supported (expression inside another expression)');
|
||||
}
|
||||
|
||||
// Check for empty expressions
|
||||
|
||||
@@ -62,6 +62,7 @@ export const workflowSettingsSchema = z.object({
|
||||
executionTimeout: z.number().optional(),
|
||||
errorWorkflow: z.string().optional(),
|
||||
callerPolicy: z.enum(['any', 'workflowsFromSameOwner', 'workflowsFromAList']).optional(),
|
||||
availableInMCP: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Default settings for workflow creation
|
||||
@@ -181,7 +182,9 @@ export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
|
||||
'executionTimeout',
|
||||
'errorWorkflow',
|
||||
'timezone',
|
||||
'executionOrder'
|
||||
'executionOrder',
|
||||
'callerPolicy',
|
||||
'availableInMCP',
|
||||
];
|
||||
|
||||
if (cleanedWorkflow.settings && typeof cleanedWorkflow.settings === 'object') {
|
||||
|
||||
@@ -383,14 +383,11 @@ export class WorkflowValidator {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Normalize node type FIRST to ensure consistent lookup
|
||||
// Normalize node type for database lookup (DO NOT mutate the original workflow)
|
||||
// The normalizer converts to short form (nodes-base.*) for database queries,
|
||||
// but n8n API requires full form (n8n-nodes-base.*). Never modify the input workflow.
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type);
|
||||
|
||||
// Update node type in place if it was normalized
|
||||
if (normalizedType !== node.type) {
|
||||
node.type = normalizedType;
|
||||
}
|
||||
|
||||
// Get node definition using normalized type (needed for typeVersion validation)
|
||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
|
||||
@@ -684,7 +681,12 @@ export class WorkflowValidator {
|
||||
}
|
||||
|
||||
// Special validation for SplitInBatches node
|
||||
if (sourceNode && sourceNode.type === 'nodes-base.splitInBatches') {
|
||||
// Check both full form (n8n-nodes-base.*) and short form (nodes-base.*)
|
||||
const isSplitInBatches = sourceNode && (
|
||||
sourceNode.type === 'n8n-nodes-base.splitInBatches' ||
|
||||
sourceNode.type === 'nodes-base.splitInBatches'
|
||||
);
|
||||
if (isSplitInBatches) {
|
||||
this.validateSplitInBatchesConnection(
|
||||
sourceNode,
|
||||
outputIndex,
|
||||
@@ -696,8 +698,8 @@ export class WorkflowValidator {
|
||||
|
||||
// Check for self-referencing connections
|
||||
if (connection.node === sourceName) {
|
||||
// This is only a warning for non-loop nodes
|
||||
if (sourceNode && sourceNode.type !== 'nodes-base.splitInBatches') {
|
||||
// This is only a warning for non-loop nodes (not SplitInBatches)
|
||||
if (sourceNode && !isSplitInBatches) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.`
|
||||
|
||||
149
src/triggers/handlers/base-handler.ts
Normal file
149
src/triggers/handlers/base-handler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
141
src/triggers/handlers/chat-handler.ts
Normal file
141
src/triggers/handlers/chat-handler.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/triggers/handlers/form-handler.ts
Normal file
117
src/triggers/handlers/form-handler.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Form trigger handler
|
||||
*
|
||||
* Handles form-based workflow triggers:
|
||||
* - POST to /form/<workflowId> or /form-test/<workflowId>
|
||||
* - Passes form fields as request body
|
||||
* - Workflow must be active (for production endpoint)
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { Workflow, WebhookRequest } 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 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();
|
||||
|
||||
try {
|
||||
// Build form URL
|
||||
const baseUrl = this.getBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
|
||||
}
|
||||
|
||||
// Form triggers use /form/<path> endpoint
|
||||
// The path can be from trigger info or workflow ID
|
||||
const formPath = triggerInfo?.node?.parameters?.path || input.workflowId;
|
||||
const formUrl = `${baseUrl.replace(/\/+$/, '')}/form/${formPath}`;
|
||||
|
||||
// Merge formData and data (formData takes precedence)
|
||||
const formFields = {
|
||||
...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 request config
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'POST',
|
||||
url: formUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...input.headers,
|
||||
},
|
||||
data: formFields,
|
||||
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
|
||||
validateStatus: (status) => status < 500,
|
||||
};
|
||||
|
||||
// Make the request
|
||||
const response = await axios.request(config);
|
||||
|
||||
return this.normalizeResponse(response.data, input, startTime, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
},
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/triggers/handlers/webhook-handler.ts
Normal file
125
src/triggers/handlers/webhook-handler.ts
Normal 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
46
src/triggers/index.ts
Normal 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';
|
||||
301
src/triggers/trigger-detector.ts
Normal file
301
src/triggers/trigger-detector.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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);
|
||||
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);
|
||||
|
||||
return {
|
||||
type: 'form',
|
||||
node,
|
||||
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);
|
||||
|
||||
return {
|
||||
type: 'chat',
|
||||
node,
|
||||
webhookPath,
|
||||
chatConfig: {
|
||||
responseMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract webhook path from node parameters
|
||||
*/
|
||||
function extractWebhookPath(params: Record<string, unknown>, nodeId: 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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':
|
||||
case 'chat': {
|
||||
const prefix = mode === 'test' ? 'webhook-test' : 'webhook';
|
||||
const path = trigger.webhookPath || trigger.node.id;
|
||||
return `${cleanBaseUrl}/${prefix}/${path}`;
|
||||
}
|
||||
|
||||
case 'form': {
|
||||
// Form triggers use /form/<workflowId> endpoint
|
||||
const prefix = mode === 'test' ? 'form-test' : 'form';
|
||||
return `${cleanBaseUrl}/${prefix}/${trigger.node.id}`;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
118
src/triggers/trigger-registry.ts
Normal file
118
src/triggers/trigger-registry.ts
Normal 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
137
src/triggers/types.ts
Normal 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;
|
||||
}
|
||||
265
tests/unit/mcp/handlers-deploy-template.test.ts
Normal file
265
tests/unit/mcp/handlers-deploy-template.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Unit tests for handleDeployTemplate handler - input validation and schema tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Test the schema directly without needing full API mocking
|
||||
const deployTemplateSchema = z.object({
|
||||
templateId: z.number().positive().int(),
|
||||
name: z.string().optional(),
|
||||
autoUpgradeVersions: z.boolean().default(true),
|
||||
autoFix: z.boolean().default(true),
|
||||
stripCredentials: z.boolean().default(true)
|
||||
});
|
||||
|
||||
describe('handleDeployTemplate Schema Validation', () => {
|
||||
describe('Input Schema', () => {
|
||||
it('should require templateId as a positive integer', () => {
|
||||
// Valid input
|
||||
const validResult = deployTemplateSchema.safeParse({ templateId: 123 });
|
||||
expect(validResult.success).toBe(true);
|
||||
|
||||
// Invalid: missing templateId
|
||||
const missingResult = deployTemplateSchema.safeParse({});
|
||||
expect(missingResult.success).toBe(false);
|
||||
|
||||
// Invalid: templateId as string
|
||||
const stringResult = deployTemplateSchema.safeParse({ templateId: '123' });
|
||||
expect(stringResult.success).toBe(false);
|
||||
|
||||
// Invalid: negative templateId
|
||||
const negativeResult = deployTemplateSchema.safeParse({ templateId: -1 });
|
||||
expect(negativeResult.success).toBe(false);
|
||||
|
||||
// Invalid: zero templateId
|
||||
const zeroResult = deployTemplateSchema.safeParse({ templateId: 0 });
|
||||
expect(zeroResult.success).toBe(false);
|
||||
|
||||
// Invalid: decimal templateId
|
||||
const decimalResult = deployTemplateSchema.safeParse({ templateId: 123.5 });
|
||||
expect(decimalResult.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept optional name parameter', () => {
|
||||
const withName = deployTemplateSchema.safeParse({
|
||||
templateId: 123,
|
||||
name: 'Custom Name'
|
||||
});
|
||||
expect(withName.success).toBe(true);
|
||||
if (withName.success) {
|
||||
expect(withName.data.name).toBe('Custom Name');
|
||||
}
|
||||
|
||||
const withoutName = deployTemplateSchema.safeParse({ templateId: 123 });
|
||||
expect(withoutName.success).toBe(true);
|
||||
if (withoutName.success) {
|
||||
expect(withoutName.data.name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should default autoUpgradeVersions to true', () => {
|
||||
const result = deployTemplateSchema.safeParse({ templateId: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.autoUpgradeVersions).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow setting autoUpgradeVersions to false', () => {
|
||||
const result = deployTemplateSchema.safeParse({
|
||||
templateId: 123,
|
||||
autoUpgradeVersions: false
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.autoUpgradeVersions).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should default autoFix to true', () => {
|
||||
const result = deployTemplateSchema.safeParse({ templateId: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.autoFix).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should default stripCredentials to true', () => {
|
||||
const result = deployTemplateSchema.safeParse({ templateId: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.stripCredentials).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept all parameters together', () => {
|
||||
const result = deployTemplateSchema.safeParse({
|
||||
templateId: 2776,
|
||||
name: 'My Deployed Workflow',
|
||||
autoUpgradeVersions: false,
|
||||
autoFix: false,
|
||||
stripCredentials: false
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.templateId).toBe(2776);
|
||||
expect(result.data.name).toBe('My Deployed Workflow');
|
||||
expect(result.data.autoUpgradeVersions).toBe(false);
|
||||
expect(result.data.autoFix).toBe(false);
|
||||
expect(result.data.stripCredentials).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDeployTemplate Helper Functions', () => {
|
||||
describe('Credential Extraction Logic', () => {
|
||||
it('should extract credential types from node credentials object', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
credentials: {
|
||||
slackApi: { id: 'cred-1', name: 'My Slack' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
name: 'Google Sheets',
|
||||
type: 'n8n-nodes-base.googleSheets',
|
||||
credentials: {
|
||||
googleSheetsOAuth2Api: { id: 'cred-2', name: 'My Google' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'node-3',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set'
|
||||
// No credentials
|
||||
}
|
||||
];
|
||||
|
||||
// Simulate the credential extraction logic from the handler
|
||||
const requiredCredentials: Array<{
|
||||
nodeType: string;
|
||||
nodeName: string;
|
||||
credentialType: string;
|
||||
}> = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.credentials && typeof node.credentials === 'object') {
|
||||
for (const [credType] of Object.entries(node.credentials)) {
|
||||
requiredCredentials.push({
|
||||
nodeType: node.type,
|
||||
nodeName: node.name,
|
||||
credentialType: credType
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(requiredCredentials).toHaveLength(2);
|
||||
expect(requiredCredentials[0]).toEqual({
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
nodeName: 'Slack',
|
||||
credentialType: 'slackApi'
|
||||
});
|
||||
expect(requiredCredentials[1]).toEqual({
|
||||
nodeType: 'n8n-nodes-base.googleSheets',
|
||||
nodeName: 'Google Sheets',
|
||||
credentialType: 'googleSheetsOAuth2Api'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Credential Stripping Logic', () => {
|
||||
it('should remove credentials property from nodes', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: { channel: '#general' },
|
||||
credentials: {
|
||||
slackApi: { id: 'cred-1', name: 'My Slack' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Simulate the credential stripping logic from the handler
|
||||
const strippedNodes = nodes.map((node: any) => {
|
||||
const { credentials, ...rest } = node;
|
||||
return rest;
|
||||
});
|
||||
|
||||
expect(strippedNodes[0].credentials).toBeUndefined();
|
||||
expect(strippedNodes[0].id).toBe('node-1');
|
||||
expect(strippedNodes[0].name).toBe('Slack');
|
||||
expect(strippedNodes[0].parameters).toEqual({ channel: '#general' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trigger Type Detection Logic', () => {
|
||||
it('should identify trigger nodes', () => {
|
||||
const testCases = [
|
||||
{ type: 'n8n-nodes-base.scheduleTrigger', expected: 'scheduleTrigger' },
|
||||
{ type: 'n8n-nodes-base.webhook', expected: 'webhook' },
|
||||
{ type: 'n8n-nodes-base.emailReadImapTrigger', expected: 'emailReadImapTrigger' },
|
||||
{ type: 'n8n-nodes-base.googleDriveTrigger', expected: 'googleDriveTrigger' }
|
||||
];
|
||||
|
||||
for (const { type, expected } of testCases) {
|
||||
const nodes = [{ type, name: 'Trigger' }];
|
||||
|
||||
// Simulate the trigger detection logic from the handler
|
||||
const triggerNode = nodes.find((n: any) =>
|
||||
n.type?.includes('Trigger') ||
|
||||
n.type?.includes('webhook') ||
|
||||
n.type === 'n8n-nodes-base.webhook'
|
||||
);
|
||||
const triggerType = triggerNode?.type?.split('.').pop() || 'manual';
|
||||
|
||||
expect(triggerType).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return manual for workflows without trigger', () => {
|
||||
const nodes = [
|
||||
{ type: 'n8n-nodes-base.set', name: 'Set' },
|
||||
{ type: 'n8n-nodes-base.httpRequest', name: 'HTTP Request' }
|
||||
];
|
||||
|
||||
const triggerNode = nodes.find((n: any) =>
|
||||
n.type?.includes('Trigger') ||
|
||||
n.type?.includes('webhook') ||
|
||||
n.type === 'n8n-nodes-base.webhook'
|
||||
);
|
||||
const triggerType = triggerNode?.type?.split('.').pop() || 'manual';
|
||||
|
||||
expect(triggerType).toBe('manual');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Definition Validation', () => {
|
||||
it('should have correct tool name', () => {
|
||||
// This tests that the tool is properly exported
|
||||
const toolName = 'n8n_deploy_template';
|
||||
expect(toolName).toBe('n8n_deploy_template');
|
||||
});
|
||||
|
||||
it('should have required parameter templateId in schema', () => {
|
||||
// Validate that the schema correctly requires templateId
|
||||
const validResult = deployTemplateSchema.safeParse({ templateId: 123 });
|
||||
const invalidResult = deployTemplateSchema.safeParse({});
|
||||
|
||||
expect(validResult.success).toBe(true);
|
||||
expect(invalidResult.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1072,10 +1072,10 @@ describe('handlers-n8n-manager', () => {
|
||||
enabled: true,
|
||||
},
|
||||
managementTools: {
|
||||
count: 12,
|
||||
count: 13,
|
||||
enabled: true,
|
||||
},
|
||||
totalAvailable: 19,
|
||||
totalAvailable: 20,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -403,45 +403,47 @@ describe('n8n-validation', () => {
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
saveDataSuccessExecution: 'none' as const,
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Filtered out (not in OpenAPI spec)
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Now whitelisted (n8n 1.121+)
|
||||
timeSavedPerExecution: 5, // Filtered out (UI-only property)
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// Unsafe properties filtered out, safe properties kept
|
||||
// Unsafe properties filtered out, safe properties kept (callerPolicy now whitelisted)
|
||||
expect(cleaned.settings).toEqual({
|
||||
executionOrder: 'v1',
|
||||
saveDataSuccessExecution: 'none'
|
||||
saveDataSuccessExecution: 'none',
|
||||
callerPolicy: 'workflowsFromSameOwner'
|
||||
});
|
||||
expect(cleaned.settings).not.toHaveProperty('callerPolicy');
|
||||
expect(cleaned.settings).not.toHaveProperty('timeSavedPerExecution');
|
||||
});
|
||||
|
||||
it('should filter out callerPolicy (Issue #248 - API limitation)', () => {
|
||||
it('should preserve callerPolicy and availableInMCP (n8n 1.121+ settings)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Filtered out
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Now whitelisted
|
||||
availableInMCP: true, // New in n8n 1.121
|
||||
errorWorkflow: 'N2O2nZy3aUiBRGFN',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// callerPolicy filtered out (causes API errors), safe properties kept
|
||||
// callerPolicy and availableInMCP now whitelisted (n8n 1.121+)
|
||||
expect(cleaned.settings).toEqual({
|
||||
executionOrder: 'v1',
|
||||
callerPolicy: 'workflowsFromSameOwner',
|
||||
availableInMCP: true,
|
||||
errorWorkflow: 'N2O2nZy3aUiBRGFN'
|
||||
});
|
||||
expect(cleaned.settings).not.toHaveProperty('callerPolicy');
|
||||
});
|
||||
|
||||
it('should filter all settings properties correctly (Issue #248 - API design)', () => {
|
||||
it('should preserve all whitelisted settings properties including callerPolicy (Issue #248 - updated for n8n 1.121)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
@@ -455,14 +457,14 @@ describe('n8n-validation', () => {
|
||||
saveExecutionProgress: false,
|
||||
executionTimeout: 300,
|
||||
errorWorkflow: 'error-workflow-id',
|
||||
callerPolicy: 'workflowsFromAList' as const, // Filtered out (not in OpenAPI spec)
|
||||
callerPolicy: 'workflowsFromAList' as const, // Now whitelisted (n8n 1.121+)
|
||||
availableInMCP: false, // New in n8n 1.121
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// Safe properties kept, unsafe properties filtered out
|
||||
// See: https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916
|
||||
// All whitelisted properties kept including callerPolicy and availableInMCP
|
||||
expect(cleaned.settings).toEqual({
|
||||
executionOrder: 'v0',
|
||||
timezone: 'UTC',
|
||||
@@ -471,9 +473,10 @@ describe('n8n-validation', () => {
|
||||
saveManualExecutions: false,
|
||||
saveExecutionProgress: false,
|
||||
executionTimeout: 300,
|
||||
errorWorkflow: 'error-workflow-id'
|
||||
errorWorkflow: 'error-workflow-id',
|
||||
callerPolicy: 'workflowsFromAList',
|
||||
availableInMCP: false
|
||||
});
|
||||
expect(cleaned.settings).not.toHaveProperty('callerPolicy');
|
||||
});
|
||||
|
||||
it('should handle workflows without settings gracefully', () => {
|
||||
@@ -494,7 +497,6 @@ describe('n8n-validation', () => {
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Filtered out
|
||||
timeSavedPerExecution: 5, // Filtered out (UI-only)
|
||||
someOtherProperty: 'value', // Filtered out
|
||||
},
|
||||
@@ -514,19 +516,19 @@ describe('n8n-validation', () => {
|
||||
connections: {},
|
||||
settings: {
|
||||
executionOrder: 'v1' as const, // Whitelisted
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Filtered out
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Now whitelisted (n8n 1.121+)
|
||||
timezone: 'America/New_York', // Whitelisted
|
||||
someOtherProperty: 'value', // Filtered out
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
// Should keep only whitelisted properties
|
||||
// Should keep only whitelisted properties (callerPolicy now whitelisted)
|
||||
expect(cleaned.settings).toEqual({
|
||||
executionOrder: 'v1',
|
||||
callerPolicy: 'workflowsFromSameOwner',
|
||||
timezone: 'America/New_York'
|
||||
});
|
||||
expect(cleaned.settings).not.toHaveProperty('callerPolicy');
|
||||
expect(cleaned.settings).not.toHaveProperty('someOtherProperty');
|
||||
});
|
||||
});
|
||||
|
||||
335
tests/unit/triggers/handlers/base-handler.test.ts
Normal file
335
tests/unit/triggers/handlers/base-handler.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
569
tests/unit/triggers/handlers/chat-handler.test.ts
Normal file
569
tests/unit/triggers/handlers/chat-handler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
578
tests/unit/triggers/handlers/form-handler.test.ts
Normal file
578
tests/unit/triggers/handlers/form-handler.test.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// 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', 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',
|
||||
data: {
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@example.com',
|
||||
message: 'Hello',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
field1: 'from data',
|
||||
field2: 'from formData',
|
||||
field3: 'from formData',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when base URL not available', async () => {
|
||||
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', 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);
|
||||
|
||||
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: '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');
|
||||
expect(response.details).toEqual({
|
||||
id: 'exec-111',
|
||||
error: 'Validation failed',
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex form data types', 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);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
name: 'Test User',
|
||||
age: 30,
|
||||
active: true,
|
||||
tags: ['tag1', 'tag2'],
|
||||
metadata: { key: 'value' },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
525
tests/unit/triggers/handlers/webhook-handler.test.ts
Normal file
525
tests/unit/triggers/handlers/webhook-handler.test.ts
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
330
tests/unit/triggers/trigger-detector.test.ts
Normal file
330
tests/unit/triggers/trigger-detector.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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', () => {
|
||||
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');
|
||||
|
||||
expect(url).toBe('https://n8n.example.com/webhook/ai-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');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
156
tests/unit/triggers/trigger-registry.test.ts
Normal file
156
tests/unit/triggers/trigger-registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user