Compare commits

...

9 Commits

Author SHA1 Message Date
Romuald Członkowski
33690c5650 feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support (#460)
* feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

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

Added new `n8n_test_workflow` tool replacing `n8n_trigger_webhook_workflow`:

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add comprehensive unit tests for trigger handlers

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 15:55:14 +01:00
Romuald Członkowski
ddf9556759 feat: n8n_deploy_template deploy-first with auto-fix (v2.27.2) (#457)
* feat: n8n_deploy_template deploy-first with auto-fix (v2.27.2)

Improved template deployment to deploy first, then automatically fix common
issues. This dramatically improves deployment success rates for templates
with expression format issues.

Key Changes:
- Deploy-first behavior: templates deployed before validation
- Auto-fix runs automatically after deployment (configurable via `autoFix`)
- Returns `fixesApplied` array showing all corrections made
- Fixed expression validator "nested expressions" false positive
- Fixed Zod schema missing `typeversion-upgrade` and `version-migration` fix types

Testing: 87% deployment success rate across 15 diverse templates

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

Co-Authored-By: Claude <noreply@anthropic.com>

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

* fix: address code review findings for deploy template

Code review fixes:
- CRITICAL: Update test schema to use `autoFix` instead of old `validate` parameter
- WARNING: Add `AppliedFix` and `AutofixResultData` interfaces for type safety
- WARNING: Add `autoFixStatus` field to response (success/failed/skipped)
- WARNING: Report auto-fix failure in response message

Changes:
- tests/unit/mcp/handlers-deploy-template.test.ts: Fixed schema and test cases
- src/mcp/handlers-n8n-manager.ts: Added type definitions, autoFixStatus tracking
- src/mcp/tool-docs/workflow_management/n8n-deploy-template.ts: Updated docs

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-29 16:10:14 +01:00
Romuald Członkowski
7d9b456887 fix: pin MCP SDK version in Docker build files (v2.27.1) (#456)
* fix: pin MCP SDK version in Docker build files (#454)

The Docker image 2.27.0 was missing the Zod fix from #450 because:
- package.runtime.json had @modelcontextprotocol/sdk@^1.13.2
- Dockerfile builder had @modelcontextprotocol/sdk@^1.12.1

Both now use the pinned version 1.20.1 (no caret) to match package.json.
Also pinned zod@3.24.1 in Dockerfile for consistency.

Fixes #454

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: bump version to 2.27.1 and update CHANGELOG

- Version bump from 2.27.0 to 2.27.1
- Added CHANGELOG entry for #454 fix (Docker SDK version)
- Added missing CHANGELOG entry for 2.27.0 (n8n_deploy_template)

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-29 10:13:16 +01:00
devangkantharia
2f5a857142 Added Antigravity Setup Instructions (#452)
* Add Antigravity setup documentation

Document the setup process for Antigravity with n8n MCP server, including preconditions, installation steps, configuration, and best practices.

* Add Antigravity integration guide to README

Added a new section for Antigravity integration.
2025-11-29 00:56:12 +01:00
Romuald Członkowski
e7dd04b471 feat: add n8n_deploy_template tool for one-click template deployment (v2.27.0) (#453)
* feat: add n8n_deploy_template tool for one-click template deployment (v2.27.0)

Add new MCP tool that deploys n8n.io workflow templates directly to user's
n8n instance in a single operation.

Features:
- Fetch template from local database
- Extract and report required credentials
- Optionally strip credentials (default: true)
- Optionally auto-upgrade node typeVersions (default: true)
- Optionally validate before deployment (default: true)
- Return workflow ID, URL, and setup guidance

Parameters:
- templateId (required): Template ID from n8n.io
- name (optional): Custom workflow name
- autoUpgradeVersions (default: true)
- validate (default: true)
- stripCredentials (default: true)

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: address code review findings for n8n_deploy_template

- Fix health check tool count (12 → 13) to include new tool
- Add templateId validation (must be positive integer)
- Use deep copy of workflow to prevent template mutation
- Expand unit tests with negative/zero/decimal validation cases

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update README with n8n_deploy_template tool

- Update management tools count from 12 to 13
- Add n8n_deploy_template to the tools list

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: prevent workflow validator from mutating node types

The validator was incorrectly mutating node types from full form
(n8n-nodes-base.*) to short form (nodes-base.*) during validation.
This caused deployed workflows to show "?" icons in n8n UI because
the API requires full form node types.

Also updated SplitInBatches detection to check both node type forms
since workflows may contain either format.

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

* test: update tool counts in handlers-n8n-manager test

Update expected managementTools count from 12 to 13 and
totalAvailable from 19 to 20 to account for the new
n8n_deploy_template tool.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: pin MCP SDK and Zod versions to prevent Zod v4 resolution

Fixes #440, #444, #446, #447, #450

Root cause: package.json declared `"@modelcontextprotocol/sdk": "^1.20.1"`
which allowed npm to resolve to SDK 1.23.0. That version accepts
`"zod": "^3.25 || ^4.0"`, causing npm to deduplicate to Zod v4.
SDK 1.23.0's `isZ4Schema()` function crashes when called with undefined,
which happens for plain JSON Schema objects.

Changes:
- Pin SDK to exact version 1.20.1 (removes ^ prefix)
- Pin Zod to exact version 3.24.1 (removes ^ prefix)
- Add CI workflow to verify fresh installs get compatible versions

The new CI workflow:
- Packs and installs package fresh (without lockfile)
- Verifies SDK is exactly 1.20.1
- Verifies Zod is NOT v4 (blocks 4.x.x)
- Runs weekly to catch upstream dependency changes

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: improve dependency-check workflow based on code review

- Add workflow_dispatch for manual triggering/debugging
- Add explicit "not found" handling for version detection failures
- Use regex pattern for Zod v4 check to catch pre-release versions
- Add Zod error pattern detection in functionality test
- Capture stderr output for comprehensive error checking

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

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-29 00:48:26 +01:00
Romuald Członkowski
c7e7bda505 fix: remove historical migration info from tools documentation (v2.26.5) (#448)
- Remove "Replaces X, Y, Z..." sentences from full.description in:
  - get_node, validate_node, search_templates, n8n_executions, n8n_get_workflow
- Remove version/issue references from n8n_update_partial_workflow
- Clean up consolidation comments in index.ts
- Documentation now starts directly with functional content
- Estimated token savings: ~128 tokens per full documentation request

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-27 13:50:45 +01:00
Romuald Członkowski
bac4936c6d fix: add n8n 1.121 availableInMCP and callerPolicy settings support (v2.26.4) (#445)
* fix: add n8n 1.121 availableInMCP and callerPolicy settings support (v2.26.4)

n8n 1.121 introduced a new workflow setting `availableInMCP` (boolean)
that controls whether a workflow is "Available in MCP". The sanitization
whitelist was missing this field, causing it to be silently stripped
during workflow updates.

Changes:
- Added `availableInMCP` to Zod schema in workflowSettingsSchema
- Added `availableInMCP` and `callerPolicy` to safeSettingsProperties whitelist
- Both settings are now preserved during workflow updates
- Settings can be toggled via updateSettings operation

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

Co-Authored-By: Claude <noreply@anthropic.com>
Conceived by Romuald Członkowski - www.aiadvisors.pl/en

* test: update tests for callerPolicy and availableInMCP whitelist changes

Updated 5 tests in n8n-validation.test.ts that expected callerPolicy
to be filtered out. Since callerPolicy and availableInMCP are now
whitelisted (n8n 1.121+), the tests now verify these settings are
preserved during workflow updates.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-26 20:17:34 +01:00
Romuald Członkowski
25784142fe fix: address tools documentation gaps and outdated references (v2.26.3) (#443) 2025-11-26 00:57:15 +01:00
Romuald Członkowski
f770043d3d Revise quick start section in README.md
Removed quick start instructions and example JSON configuration for n8n-MCP.
2025-11-25 21:31:56 +01:00
53 changed files with 5943 additions and 324 deletions

View File

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

222
.github/workflows/dependency-check.yml vendored Normal file
View 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

View File

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

View File

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

View File

@@ -36,10 +36,6 @@ AI results can be unpredictable. Protect your work!
## 🚀 Quick Start
Get n8n-MCP running in minutes:
[![n8n-mcp Video Quickstart Guide](./thumbnail.png)](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:
[![n8n-mcp Video Quickstart Guide](./thumbnail.png)](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

Binary file not shown.

433
docs/ANTIGRAVITY_SETUP.md Normal file
View 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
View File

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

View File

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

View File

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

View File

@@ -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'
};
}
}

View File

@@ -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}`);
}

View File

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

View File

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

View File

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

View File

@@ -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';

View File

@@ -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']
}
};

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

@@ -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'
],

View 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']
}
};

View File

@@ -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']
}
};

View File

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

View File

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

View File

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

View File

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

View 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'
]
}
};

View File

@@ -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)`;
}

View File

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

View File

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

View File

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

View File

@@ -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.`

View File

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

View File

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

View File

@@ -0,0 +1,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,
});
}
}
}

View File

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

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

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

View File

@@ -0,0 +1,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';
}
}

View File

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

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

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

View File

@@ -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);
});
});

View File

@@ -1072,10 +1072,10 @@ describe('handlers-n8n-manager', () => {
enabled: true,
},
managementTools: {
count: 12,
count: 13,
enabled: true,
},
totalAvailable: 19,
totalAvailable: 20,
},
});

View File

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

View File

@@ -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');
});
});

View File

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

View File

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

View File

@@ -0,0 +1,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' },
},
})
);
});
});
});

View File

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

View File

@@ -0,0 +1,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');
});
});
});

View File

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