This is page 48 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/mcp/handlers-n8n-manager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { N8nApiClient } from '../services/n8n-api-client'; 2 | import { getN8nApiConfig, getN8nApiConfigFromContext } from '../config/n8n-api'; 3 | import { 4 | Workflow, 5 | WorkflowNode, 6 | WorkflowConnection, 7 | ExecutionStatus, 8 | WebhookRequest, 9 | McpToolResponse, 10 | ExecutionFilterOptions, 11 | ExecutionMode 12 | } from '../types/n8n-api'; 13 | import { 14 | validateWorkflowStructure, 15 | hasWebhookTrigger, 16 | getWebhookUrl 17 | } from '../services/n8n-validation'; 18 | import { 19 | N8nApiError, 20 | N8nNotFoundError, 21 | getUserFriendlyErrorMessage, 22 | formatExecutionError, 23 | formatNoExecutionError 24 | } from '../utils/n8n-errors'; 25 | import { logger } from '../utils/logger'; 26 | import { z } from 'zod'; 27 | import { WorkflowValidator } from '../services/workflow-validator'; 28 | import { EnhancedConfigValidator } from '../services/enhanced-config-validator'; 29 | import { NodeRepository } from '../database/node-repository'; 30 | import { InstanceContext, validateInstanceContext } from '../types/instance-context'; 31 | import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; 32 | import { WorkflowAutoFixer, AutoFixConfig } from '../services/workflow-auto-fixer'; 33 | import { ExpressionFormatValidator, ExpressionFormatIssue } from '../services/expression-format-validator'; 34 | import { handleUpdatePartialWorkflow } from './handlers-workflow-diff'; 35 | import { telemetry } from '../telemetry'; 36 | import { 37 | createCacheKey, 38 | createInstanceCache, 39 | CacheMutex, 40 | cacheMetrics, 41 | withRetry, 42 | getCacheStatistics 43 | } from '../utils/cache-utils'; 44 | import { processExecution } from '../services/execution-processor'; 45 | import { checkNpmVersion, formatVersionMessage } from '../utils/npm-version-checker'; 46 | 47 | // ======================================================================== 48 | // TypeScript Interfaces for Type Safety 49 | // ======================================================================== 50 | 51 | /** 52 | * Health Check Response Data Structure 53 | */ 54 | interface HealthCheckResponseData { 55 | status: string; 56 | instanceId?: string; 57 | n8nVersion?: string; 58 | features?: Record<string, unknown>; 59 | apiUrl?: string; 60 | mcpVersion: string; 61 | supportedN8nVersion?: string; 62 | versionCheck: { 63 | current: string; 64 | latest: string | null; 65 | upToDate: boolean; 66 | message: string; 67 | updateCommand?: string; 68 | }; 69 | performance: { 70 | responseTimeMs: number; 71 | cacheHitRate: string; 72 | cachedInstances: number; 73 | }; 74 | nextSteps?: string[]; 75 | updateWarning?: string; 76 | } 77 | 78 | /** 79 | * Cloud Platform Guide Structure 80 | */ 81 | interface CloudPlatformGuide { 82 | name: string; 83 | troubleshooting: string[]; 84 | } 85 | 86 | /** 87 | * Workflow Validation Response Data 88 | */ 89 | interface WorkflowValidationResponse { 90 | valid: boolean; 91 | workflowId?: string; 92 | workflowName?: string; 93 | summary: { 94 | totalNodes: number; 95 | enabledNodes: number; 96 | triggerNodes: number; 97 | validConnections: number; 98 | invalidConnections: number; 99 | expressionsValidated: number; 100 | errorCount: number; 101 | warningCount: number; 102 | }; 103 | errors?: Array<{ 104 | node: string; 105 | nodeName?: string; 106 | message: string; 107 | details?: Record<string, unknown>; 108 | }>; 109 | warnings?: Array<{ 110 | node: string; 111 | nodeName?: string; 112 | message: string; 113 | details?: Record<string, unknown>; 114 | }>; 115 | suggestions?: unknown[]; 116 | } 117 | 118 | /** 119 | * Diagnostic Response Data Structure 120 | */ 121 | interface DiagnosticResponseData { 122 | timestamp: string; 123 | environment: { 124 | N8N_API_URL: string | null; 125 | N8N_API_KEY: string | null; 126 | NODE_ENV: string; 127 | MCP_MODE: string; 128 | isDocker: boolean; 129 | cloudPlatform: string | null; 130 | nodeVersion: string; 131 | platform: string; 132 | }; 133 | apiConfiguration: { 134 | configured: boolean; 135 | status: { 136 | configured: boolean; 137 | connected: boolean; 138 | error: string | null; 139 | version: string | null; 140 | }; 141 | config: { 142 | baseUrl: string; 143 | timeout: number; 144 | maxRetries: number; 145 | } | null; 146 | }; 147 | versionInfo: { 148 | current: string; 149 | latest: string | null; 150 | upToDate: boolean; 151 | message: string; 152 | updateCommand?: string; 153 | }; 154 | toolsAvailability: { 155 | documentationTools: { 156 | count: number; 157 | enabled: boolean; 158 | description: string; 159 | }; 160 | managementTools: { 161 | count: number; 162 | enabled: boolean; 163 | description: string; 164 | }; 165 | totalAvailable: number; 166 | }; 167 | performance: { 168 | diagnosticResponseTimeMs: number; 169 | cacheHitRate: string; 170 | cachedInstances: number; 171 | }; 172 | modeSpecificDebug: Record<string, unknown>; 173 | dockerDebug?: Record<string, unknown>; 174 | cloudPlatformDebug?: CloudPlatformGuide; 175 | nextSteps?: Record<string, unknown>; 176 | troubleshooting?: Record<string, unknown>; 177 | setupGuide?: Record<string, unknown>; 178 | updateWarning?: Record<string, unknown>; 179 | debug?: Record<string, unknown>; 180 | [key: string]: unknown; // Allow dynamic property access for optional fields 181 | } 182 | 183 | // ======================================================================== 184 | // Singleton n8n API client instance (backward compatibility) 185 | let defaultApiClient: N8nApiClient | null = null; 186 | let lastDefaultConfigUrl: string | null = null; 187 | 188 | // Mutex for cache operations to prevent race conditions 189 | const cacheMutex = new CacheMutex(); 190 | 191 | // Instance-specific API clients cache with LRU eviction and TTL 192 | const instanceClients = createInstanceCache<N8nApiClient>((client, key) => { 193 | // Clean up when evicting from cache 194 | logger.debug('Evicting API client from cache', { 195 | cacheKey: key.substring(0, 8) + '...' // Only log partial key for security 196 | }); 197 | }); 198 | 199 | /** 200 | * Get or create API client with flexible instance support 201 | * Supports both singleton mode (using environment variables) and instance-specific mode. 202 | * Uses LRU cache with mutex protection for thread-safe operations. 203 | * 204 | * @param context - Optional instance context for instance-specific configuration 205 | * @returns API client configured for the instance or environment, or null if not configured 206 | * 207 | * @example 208 | * // Using environment variables (singleton mode) 209 | * const client = getN8nApiClient(); 210 | * 211 | * @example 212 | * // Using instance context 213 | * const client = getN8nApiClient({ 214 | * n8nApiUrl: 'https://customer.n8n.cloud', 215 | * n8nApiKey: 'api-key-123', 216 | * instanceId: 'customer-1' 217 | * }); 218 | */ 219 | /** 220 | * Get cache statistics for monitoring 221 | * @returns Formatted cache statistics string 222 | */ 223 | export function getInstanceCacheStatistics(): string { 224 | return getCacheStatistics(); 225 | } 226 | 227 | /** 228 | * Get raw cache metrics for detailed monitoring 229 | * @returns Raw cache metrics object 230 | */ 231 | export function getInstanceCacheMetrics() { 232 | return cacheMetrics.getMetrics(); 233 | } 234 | 235 | /** 236 | * Clear the instance cache for testing or maintenance 237 | */ 238 | export function clearInstanceCache(): void { 239 | instanceClients.clear(); 240 | cacheMetrics.recordClear(); 241 | cacheMetrics.updateSize(0, instanceClients.max); 242 | } 243 | 244 | export function getN8nApiClient(context?: InstanceContext): N8nApiClient | null { 245 | // If context provided with n8n config, use instance-specific client 246 | if (context?.n8nApiUrl && context?.n8nApiKey) { 247 | // Validate context before using 248 | const validation = validateInstanceContext(context); 249 | if (!validation.valid) { 250 | logger.warn('Invalid instance context provided', { 251 | instanceId: context.instanceId, 252 | errors: validation.errors 253 | }); 254 | return null; 255 | } 256 | // Create secure hash of credentials for cache key using memoization 257 | const cacheKey = createCacheKey( 258 | `${context.n8nApiUrl}:${context.n8nApiKey}:${context.instanceId || ''}` 259 | ); 260 | 261 | // Check cache first 262 | if (instanceClients.has(cacheKey)) { 263 | cacheMetrics.recordHit(); 264 | return instanceClients.get(cacheKey) || null; 265 | } 266 | 267 | cacheMetrics.recordMiss(); 268 | 269 | // Check if already being created (simple lock check) 270 | if (cacheMutex.isLocked(cacheKey)) { 271 | // Wait briefly and check again 272 | const waitTime = 100; // 100ms 273 | const start = Date.now(); 274 | while (cacheMutex.isLocked(cacheKey) && (Date.now() - start) < 1000) { 275 | // Busy wait for up to 1 second 276 | } 277 | // Check if it was created while waiting 278 | if (instanceClients.has(cacheKey)) { 279 | cacheMetrics.recordHit(); 280 | return instanceClients.get(cacheKey) || null; 281 | } 282 | } 283 | 284 | const config = getN8nApiConfigFromContext(context); 285 | if (config) { 286 | // Sanitized logging - never log API keys 287 | logger.info('Creating instance-specific n8n API client', { 288 | url: config.baseUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1'), // Only log domain 289 | instanceId: context.instanceId, 290 | cacheKey: cacheKey.substring(0, 8) + '...' // Only log partial hash 291 | }); 292 | 293 | const client = new N8nApiClient(config); 294 | instanceClients.set(cacheKey, client); 295 | cacheMetrics.recordSet(); 296 | cacheMetrics.updateSize(instanceClients.size, instanceClients.max); 297 | return client; 298 | } 299 | 300 | return null; 301 | } 302 | 303 | // Fall back to default singleton from environment 304 | logger.info('Falling back to environment configuration for n8n API client'); 305 | const config = getN8nApiConfig(); 306 | 307 | if (!config) { 308 | if (defaultApiClient) { 309 | logger.info('n8n API configuration removed, clearing default client'); 310 | defaultApiClient = null; 311 | lastDefaultConfigUrl = null; 312 | } 313 | return null; 314 | } 315 | 316 | // Check if config has changed 317 | if (!defaultApiClient || lastDefaultConfigUrl !== config.baseUrl) { 318 | logger.info('n8n API client initialized from environment', { url: config.baseUrl }); 319 | defaultApiClient = new N8nApiClient(config); 320 | lastDefaultConfigUrl = config.baseUrl; 321 | } 322 | 323 | return defaultApiClient; 324 | } 325 | 326 | /** 327 | * Helper to ensure API is configured 328 | * @param context - Optional instance context 329 | * @returns Configured API client 330 | * @throws Error if API is not configured 331 | */ 332 | function ensureApiConfigured(context?: InstanceContext): N8nApiClient { 333 | const client = getN8nApiClient(context); 334 | if (!client) { 335 | if (context?.instanceId) { 336 | throw new Error(`n8n API not configured for instance ${context.instanceId}. Please provide n8nApiUrl and n8nApiKey in the instance context.`); 337 | } 338 | throw new Error('n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.'); 339 | } 340 | return client; 341 | } 342 | 343 | // Zod schemas for input validation 344 | const createWorkflowSchema = z.object({ 345 | name: z.string(), 346 | nodes: z.array(z.any()), 347 | connections: z.record(z.any()), 348 | settings: z.object({ 349 | executionOrder: z.enum(['v0', 'v1']).optional(), 350 | timezone: z.string().optional(), 351 | saveDataErrorExecution: z.enum(['all', 'none']).optional(), 352 | saveDataSuccessExecution: z.enum(['all', 'none']).optional(), 353 | saveManualExecutions: z.boolean().optional(), 354 | saveExecutionProgress: z.boolean().optional(), 355 | executionTimeout: z.number().optional(), 356 | errorWorkflow: z.string().optional(), 357 | }).optional(), 358 | }); 359 | 360 | const updateWorkflowSchema = z.object({ 361 | id: z.string(), 362 | name: z.string().optional(), 363 | nodes: z.array(z.any()).optional(), 364 | connections: z.record(z.any()).optional(), 365 | settings: z.any().optional(), 366 | }); 367 | 368 | const listWorkflowsSchema = z.object({ 369 | limit: z.number().min(1).max(100).optional(), 370 | cursor: z.string().optional(), 371 | active: z.boolean().optional(), 372 | tags: z.array(z.string()).optional(), 373 | projectId: z.string().optional(), 374 | excludePinnedData: z.boolean().optional(), 375 | }); 376 | 377 | const validateWorkflowSchema = z.object({ 378 | id: z.string(), 379 | options: z.object({ 380 | validateNodes: z.boolean().optional(), 381 | validateConnections: z.boolean().optional(), 382 | validateExpressions: z.boolean().optional(), 383 | profile: z.enum(['minimal', 'runtime', 'ai-friendly', 'strict']).optional(), 384 | }).optional(), 385 | }); 386 | 387 | const autofixWorkflowSchema = z.object({ 388 | id: z.string(), 389 | applyFixes: z.boolean().optional().default(false), 390 | fixTypes: z.array(z.enum([ 391 | 'expression-format', 392 | 'typeversion-correction', 393 | 'error-output-config', 394 | 'node-type-correction', 395 | 'webhook-missing-path' 396 | ])).optional(), 397 | confidenceThreshold: z.enum(['high', 'medium', 'low']).optional().default('medium'), 398 | maxFixes: z.number().optional().default(50) 399 | }); 400 | 401 | const triggerWebhookSchema = z.object({ 402 | webhookUrl: z.string().url(), 403 | httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(), 404 | data: z.record(z.unknown()).optional(), 405 | headers: z.record(z.string()).optional(), 406 | waitForResponse: z.boolean().optional(), 407 | }); 408 | 409 | const listExecutionsSchema = z.object({ 410 | limit: z.number().min(1).max(100).optional(), 411 | cursor: z.string().optional(), 412 | workflowId: z.string().optional(), 413 | projectId: z.string().optional(), 414 | status: z.enum(['success', 'error', 'waiting']).optional(), 415 | includeData: z.boolean().optional(), 416 | }); 417 | 418 | // Workflow Management Handlers 419 | 420 | export async function handleCreateWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 421 | try { 422 | const client = ensureApiConfigured(context); 423 | const input = createWorkflowSchema.parse(args); 424 | 425 | // Proactively detect SHORT form node types (common mistake) 426 | const shortFormErrors: string[] = []; 427 | input.nodes?.forEach((node: any, index: number) => { 428 | if (node.type?.startsWith('nodes-base.') || node.type?.startsWith('nodes-langchain.')) { 429 | const fullForm = node.type.startsWith('nodes-base.') 430 | ? node.type.replace('nodes-base.', 'n8n-nodes-base.') 431 | : node.type.replace('nodes-langchain.', '@n8n/n8n-nodes-langchain.'); 432 | shortFormErrors.push( 433 | `Node ${index} ("${node.name}") uses SHORT form "${node.type}". ` + 434 | `The n8n API requires FULL form. Change to "${fullForm}"` 435 | ); 436 | } 437 | }); 438 | 439 | if (shortFormErrors.length > 0) { 440 | telemetry.trackWorkflowCreation(input, false); 441 | return { 442 | success: false, 443 | error: 'Node type format error: n8n API requires FULL form node types', 444 | details: { 445 | errors: shortFormErrors, 446 | hint: 'Use n8n-nodes-base.* instead of nodes-base.* for standard nodes' 447 | } 448 | }; 449 | } 450 | 451 | // Validate workflow structure (n8n API expects FULL form: n8n-nodes-base.*) 452 | const errors = validateWorkflowStructure(input); 453 | if (errors.length > 0) { 454 | // Track validation failure 455 | telemetry.trackWorkflowCreation(input, false); 456 | 457 | return { 458 | success: false, 459 | error: 'Workflow validation failed', 460 | details: { errors } 461 | }; 462 | } 463 | 464 | // Create workflow (n8n API expects node types in FULL form) 465 | const workflow = await client.createWorkflow(input); 466 | 467 | // Track successful workflow creation 468 | telemetry.trackWorkflowCreation(workflow, true); 469 | 470 | return { 471 | success: true, 472 | data: workflow, 473 | message: `Workflow "${workflow.name}" created successfully with ID: ${workflow.id}` 474 | }; 475 | } catch (error) { 476 | if (error instanceof z.ZodError) { 477 | return { 478 | success: false, 479 | error: 'Invalid input', 480 | details: { errors: error.errors } 481 | }; 482 | } 483 | 484 | if (error instanceof N8nApiError) { 485 | return { 486 | success: false, 487 | error: getUserFriendlyErrorMessage(error), 488 | code: error.code, 489 | details: error.details as Record<string, unknown> | undefined 490 | }; 491 | } 492 | 493 | return { 494 | success: false, 495 | error: error instanceof Error ? error.message : 'Unknown error occurred' 496 | }; 497 | } 498 | } 499 | 500 | export async function handleGetWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 501 | try { 502 | const client = ensureApiConfigured(context); 503 | const { id } = z.object({ id: z.string() }).parse(args); 504 | 505 | const workflow = await client.getWorkflow(id); 506 | 507 | return { 508 | success: true, 509 | data: workflow 510 | }; 511 | } catch (error) { 512 | if (error instanceof z.ZodError) { 513 | return { 514 | success: false, 515 | error: 'Invalid input', 516 | details: { errors: error.errors } 517 | }; 518 | } 519 | 520 | if (error instanceof N8nApiError) { 521 | return { 522 | success: false, 523 | error: getUserFriendlyErrorMessage(error), 524 | code: error.code 525 | }; 526 | } 527 | 528 | return { 529 | success: false, 530 | error: error instanceof Error ? error.message : 'Unknown error occurred' 531 | }; 532 | } 533 | } 534 | 535 | export async function handleGetWorkflowDetails(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 536 | try { 537 | const client = ensureApiConfigured(context); 538 | const { id } = z.object({ id: z.string() }).parse(args); 539 | 540 | const workflow = await client.getWorkflow(id); 541 | 542 | // Get recent executions for this workflow 543 | const executions = await client.listExecutions({ 544 | workflowId: id, 545 | limit: 10 546 | }); 547 | 548 | // Calculate execution statistics 549 | const stats = { 550 | totalExecutions: executions.data.length, 551 | successCount: executions.data.filter(e => e.status === ExecutionStatus.SUCCESS).length, 552 | errorCount: executions.data.filter(e => e.status === ExecutionStatus.ERROR).length, 553 | lastExecutionTime: executions.data[0]?.startedAt || null 554 | }; 555 | 556 | return { 557 | success: true, 558 | data: { 559 | workflow, 560 | executionStats: stats, 561 | hasWebhookTrigger: hasWebhookTrigger(workflow), 562 | webhookPath: getWebhookUrl(workflow) 563 | } 564 | }; 565 | } catch (error) { 566 | if (error instanceof z.ZodError) { 567 | return { 568 | success: false, 569 | error: 'Invalid input', 570 | details: { errors: error.errors } 571 | }; 572 | } 573 | 574 | if (error instanceof N8nApiError) { 575 | return { 576 | success: false, 577 | error: getUserFriendlyErrorMessage(error), 578 | code: error.code 579 | }; 580 | } 581 | 582 | return { 583 | success: false, 584 | error: error instanceof Error ? error.message : 'Unknown error occurred' 585 | }; 586 | } 587 | } 588 | 589 | export async function handleGetWorkflowStructure(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 590 | try { 591 | const client = ensureApiConfigured(context); 592 | const { id } = z.object({ id: z.string() }).parse(args); 593 | 594 | const workflow = await client.getWorkflow(id); 595 | 596 | // Simplify nodes to just essential structure 597 | const simplifiedNodes = workflow.nodes.map(node => ({ 598 | id: node.id, 599 | name: node.name, 600 | type: node.type, 601 | position: node.position, 602 | disabled: node.disabled || false 603 | })); 604 | 605 | return { 606 | success: true, 607 | data: { 608 | id: workflow.id, 609 | name: workflow.name, 610 | active: workflow.active, 611 | isArchived: workflow.isArchived, 612 | nodes: simplifiedNodes, 613 | connections: workflow.connections, 614 | nodeCount: workflow.nodes.length, 615 | connectionCount: Object.keys(workflow.connections).length 616 | } 617 | }; 618 | } catch (error) { 619 | if (error instanceof z.ZodError) { 620 | return { 621 | success: false, 622 | error: 'Invalid input', 623 | details: { errors: error.errors } 624 | }; 625 | } 626 | 627 | if (error instanceof N8nApiError) { 628 | return { 629 | success: false, 630 | error: getUserFriendlyErrorMessage(error), 631 | code: error.code 632 | }; 633 | } 634 | 635 | return { 636 | success: false, 637 | error: error instanceof Error ? error.message : 'Unknown error occurred' 638 | }; 639 | } 640 | } 641 | 642 | export async function handleGetWorkflowMinimal(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 643 | try { 644 | const client = ensureApiConfigured(context); 645 | const { id } = z.object({ id: z.string() }).parse(args); 646 | 647 | const workflow = await client.getWorkflow(id); 648 | 649 | return { 650 | success: true, 651 | data: { 652 | id: workflow.id, 653 | name: workflow.name, 654 | active: workflow.active, 655 | isArchived: workflow.isArchived, 656 | tags: workflow.tags || [], 657 | createdAt: workflow.createdAt, 658 | updatedAt: workflow.updatedAt 659 | } 660 | }; 661 | } catch (error) { 662 | if (error instanceof z.ZodError) { 663 | return { 664 | success: false, 665 | error: 'Invalid input', 666 | details: { errors: error.errors } 667 | }; 668 | } 669 | 670 | if (error instanceof N8nApiError) { 671 | return { 672 | success: false, 673 | error: getUserFriendlyErrorMessage(error), 674 | code: error.code 675 | }; 676 | } 677 | 678 | return { 679 | success: false, 680 | error: error instanceof Error ? error.message : 'Unknown error occurred' 681 | }; 682 | } 683 | } 684 | 685 | export async function handleUpdateWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 686 | try { 687 | const client = ensureApiConfigured(context); 688 | const input = updateWorkflowSchema.parse(args); 689 | const { id, ...updateData } = input; 690 | 691 | // If nodes/connections are being updated, validate the structure 692 | if (updateData.nodes || updateData.connections) { 693 | // Always fetch current workflow for validation (need all fields like name) 694 | const current = await client.getWorkflow(id); 695 | const fullWorkflow = { 696 | ...current, 697 | ...updateData 698 | }; 699 | 700 | // Validate workflow structure (n8n API expects FULL form: n8n-nodes-base.*) 701 | const errors = validateWorkflowStructure(fullWorkflow); 702 | if (errors.length > 0) { 703 | return { 704 | success: false, 705 | error: 'Workflow validation failed', 706 | details: { errors } 707 | }; 708 | } 709 | } 710 | 711 | // Update workflow 712 | const workflow = await client.updateWorkflow(id, updateData); 713 | 714 | return { 715 | success: true, 716 | data: workflow, 717 | message: `Workflow "${workflow.name}" updated successfully` 718 | }; 719 | } catch (error) { 720 | if (error instanceof z.ZodError) { 721 | return { 722 | success: false, 723 | error: 'Invalid input', 724 | details: { errors: error.errors } 725 | }; 726 | } 727 | 728 | if (error instanceof N8nApiError) { 729 | return { 730 | success: false, 731 | error: getUserFriendlyErrorMessage(error), 732 | code: error.code, 733 | details: error.details as Record<string, unknown> | undefined 734 | }; 735 | } 736 | 737 | return { 738 | success: false, 739 | error: error instanceof Error ? error.message : 'Unknown error occurred' 740 | }; 741 | } 742 | } 743 | 744 | export async function handleDeleteWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 745 | try { 746 | const client = ensureApiConfigured(context); 747 | const { id } = z.object({ id: z.string() }).parse(args); 748 | 749 | const deleted = await client.deleteWorkflow(id); 750 | 751 | return { 752 | success: true, 753 | data: deleted, 754 | message: `Workflow ${id} deleted successfully` 755 | }; 756 | } catch (error) { 757 | if (error instanceof z.ZodError) { 758 | return { 759 | success: false, 760 | error: 'Invalid input', 761 | details: { errors: error.errors } 762 | }; 763 | } 764 | 765 | if (error instanceof N8nApiError) { 766 | return { 767 | success: false, 768 | error: getUserFriendlyErrorMessage(error), 769 | code: error.code 770 | }; 771 | } 772 | 773 | return { 774 | success: false, 775 | error: error instanceof Error ? error.message : 'Unknown error occurred' 776 | }; 777 | } 778 | } 779 | 780 | export async function handleListWorkflows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 781 | try { 782 | const client = ensureApiConfigured(context); 783 | const input = listWorkflowsSchema.parse(args || {}); 784 | 785 | // Convert tags array to comma-separated string (n8n API format) 786 | const tagsParam = input.tags && input.tags.length > 0 787 | ? input.tags.join(',') 788 | : undefined; 789 | 790 | const response = await client.listWorkflows({ 791 | limit: input.limit || 100, 792 | cursor: input.cursor, 793 | active: input.active, 794 | tags: tagsParam as any, // API expects string, not array 795 | projectId: input.projectId, 796 | excludePinnedData: input.excludePinnedData ?? true 797 | }); 798 | 799 | // Strip down workflows to only essential metadata 800 | const minimalWorkflows = response.data.map(workflow => ({ 801 | id: workflow.id, 802 | name: workflow.name, 803 | active: workflow.active, 804 | isArchived: workflow.isArchived, 805 | createdAt: workflow.createdAt, 806 | updatedAt: workflow.updatedAt, 807 | tags: workflow.tags || [], 808 | nodeCount: workflow.nodes?.length || 0 809 | })); 810 | 811 | return { 812 | success: true, 813 | data: { 814 | workflows: minimalWorkflows, 815 | returned: minimalWorkflows.length, 816 | nextCursor: response.nextCursor, 817 | hasMore: !!response.nextCursor, 818 | ...(response.nextCursor ? { 819 | _note: "More workflows available. Use cursor to get next page." 820 | } : {}) 821 | } 822 | }; 823 | } catch (error) { 824 | if (error instanceof z.ZodError) { 825 | return { 826 | success: false, 827 | error: 'Invalid input', 828 | details: { errors: error.errors } 829 | }; 830 | } 831 | 832 | if (error instanceof N8nApiError) { 833 | return { 834 | success: false, 835 | error: getUserFriendlyErrorMessage(error), 836 | code: error.code 837 | }; 838 | } 839 | 840 | return { 841 | success: false, 842 | error: error instanceof Error ? error.message : 'Unknown error occurred' 843 | }; 844 | } 845 | } 846 | 847 | export async function handleValidateWorkflow( 848 | args: unknown, 849 | repository: NodeRepository, 850 | context?: InstanceContext 851 | ): Promise<McpToolResponse> { 852 | try { 853 | const client = ensureApiConfigured(context); 854 | const input = validateWorkflowSchema.parse(args); 855 | 856 | // First, fetch the workflow from n8n 857 | const workflowResponse = await handleGetWorkflow({ id: input.id }); 858 | 859 | if (!workflowResponse.success) { 860 | return workflowResponse; // Return the error from fetching 861 | } 862 | 863 | const workflow = workflowResponse.data as Workflow; 864 | 865 | // Create validator instance using the provided repository 866 | const validator = new WorkflowValidator(repository, EnhancedConfigValidator); 867 | 868 | // Run validation 869 | const validationResult = await validator.validateWorkflow(workflow, input.options); 870 | 871 | // Format the response (same format as the regular validate_workflow tool) 872 | const response: WorkflowValidationResponse = { 873 | valid: validationResult.valid, 874 | workflowId: workflow.id, 875 | workflowName: workflow.name, 876 | summary: { 877 | totalNodes: validationResult.statistics.totalNodes, 878 | enabledNodes: validationResult.statistics.enabledNodes, 879 | triggerNodes: validationResult.statistics.triggerNodes, 880 | validConnections: validationResult.statistics.validConnections, 881 | invalidConnections: validationResult.statistics.invalidConnections, 882 | expressionsValidated: validationResult.statistics.expressionsValidated, 883 | errorCount: validationResult.errors.length, 884 | warningCount: validationResult.warnings.length 885 | } 886 | }; 887 | 888 | if (validationResult.errors.length > 0) { 889 | response.errors = validationResult.errors.map(e => ({ 890 | node: e.nodeName || 'workflow', 891 | nodeName: e.nodeName, // Also set nodeName for compatibility 892 | message: e.message, 893 | details: e.details 894 | })); 895 | } 896 | 897 | if (validationResult.warnings.length > 0) { 898 | response.warnings = validationResult.warnings.map(w => ({ 899 | node: w.nodeName || 'workflow', 900 | nodeName: w.nodeName, // Also set nodeName for compatibility 901 | message: w.message, 902 | details: w.details 903 | })); 904 | } 905 | 906 | if (validationResult.suggestions.length > 0) { 907 | response.suggestions = validationResult.suggestions; 908 | } 909 | 910 | // Track successfully validated workflows in telemetry 911 | if (validationResult.valid) { 912 | telemetry.trackWorkflowCreation(workflow, true); 913 | } 914 | 915 | return { 916 | success: true, 917 | data: response 918 | }; 919 | } catch (error) { 920 | if (error instanceof z.ZodError) { 921 | return { 922 | success: false, 923 | error: 'Invalid input', 924 | details: { errors: error.errors } 925 | }; 926 | } 927 | 928 | if (error instanceof N8nApiError) { 929 | return { 930 | success: false, 931 | error: getUserFriendlyErrorMessage(error), 932 | code: error.code 933 | }; 934 | } 935 | 936 | return { 937 | success: false, 938 | error: error instanceof Error ? error.message : 'Unknown error occurred' 939 | }; 940 | } 941 | } 942 | 943 | export async function handleAutofixWorkflow( 944 | args: unknown, 945 | repository: NodeRepository, 946 | context?: InstanceContext 947 | ): Promise<McpToolResponse> { 948 | try { 949 | const client = ensureApiConfigured(context); 950 | const input = autofixWorkflowSchema.parse(args); 951 | 952 | // First, fetch the workflow from n8n 953 | const workflowResponse = await handleGetWorkflow({ id: input.id }, context); 954 | 955 | if (!workflowResponse.success) { 956 | return workflowResponse; // Return the error from fetching 957 | } 958 | 959 | const workflow = workflowResponse.data as Workflow; 960 | 961 | // Create validator instance using the provided repository 962 | const validator = new WorkflowValidator(repository, EnhancedConfigValidator); 963 | 964 | // Run validation to identify issues 965 | const validationResult = await validator.validateWorkflow(workflow, { 966 | validateNodes: true, 967 | validateConnections: true, 968 | validateExpressions: true, 969 | profile: 'ai-friendly' 970 | }); 971 | 972 | // Check for expression format issues 973 | const allFormatIssues: ExpressionFormatIssue[] = []; 974 | for (const node of workflow.nodes) { 975 | const formatContext = { 976 | nodeType: node.type, 977 | nodeName: node.name, 978 | nodeId: node.id 979 | }; 980 | 981 | const nodeFormatIssues = ExpressionFormatValidator.validateNodeParameters( 982 | node.parameters, 983 | formatContext 984 | ); 985 | 986 | // Add node information to each format issue 987 | const enrichedIssues = nodeFormatIssues.map(issue => ({ 988 | ...issue, 989 | nodeName: node.name, 990 | nodeId: node.id 991 | })); 992 | 993 | allFormatIssues.push(...enrichedIssues); 994 | } 995 | 996 | // Generate fixes using WorkflowAutoFixer 997 | const autoFixer = new WorkflowAutoFixer(repository); 998 | const fixResult = autoFixer.generateFixes( 999 | workflow, 1000 | validationResult, 1001 | allFormatIssues, 1002 | { 1003 | applyFixes: input.applyFixes, 1004 | fixTypes: input.fixTypes, 1005 | confidenceThreshold: input.confidenceThreshold, 1006 | maxFixes: input.maxFixes 1007 | } 1008 | ); 1009 | 1010 | // If no fixes available 1011 | if (fixResult.fixes.length === 0) { 1012 | return { 1013 | success: true, 1014 | data: { 1015 | workflowId: workflow.id, 1016 | workflowName: workflow.name, 1017 | message: 'No automatic fixes available for this workflow', 1018 | validationSummary: { 1019 | errors: validationResult.errors.length, 1020 | warnings: validationResult.warnings.length 1021 | } 1022 | } 1023 | }; 1024 | } 1025 | 1026 | // If preview mode (applyFixes = false) 1027 | if (!input.applyFixes) { 1028 | return { 1029 | success: true, 1030 | data: { 1031 | workflowId: workflow.id, 1032 | workflowName: workflow.name, 1033 | preview: true, 1034 | fixesAvailable: fixResult.fixes.length, 1035 | fixes: fixResult.fixes, 1036 | summary: fixResult.summary, 1037 | stats: fixResult.stats, 1038 | message: `${fixResult.fixes.length} fixes available. Set applyFixes=true to apply them.` 1039 | } 1040 | }; 1041 | } 1042 | 1043 | // Apply fixes using the diff engine 1044 | if (fixResult.operations.length > 0) { 1045 | const updateResult = await handleUpdatePartialWorkflow( 1046 | { 1047 | id: workflow.id, 1048 | operations: fixResult.operations 1049 | }, 1050 | context 1051 | ); 1052 | 1053 | if (!updateResult.success) { 1054 | return { 1055 | success: false, 1056 | error: 'Failed to apply fixes', 1057 | details: { 1058 | fixes: fixResult.fixes, 1059 | updateError: updateResult.error 1060 | } 1061 | }; 1062 | } 1063 | 1064 | return { 1065 | success: true, 1066 | data: { 1067 | workflowId: workflow.id, 1068 | workflowName: workflow.name, 1069 | fixesApplied: fixResult.fixes.length, 1070 | fixes: fixResult.fixes, 1071 | summary: fixResult.summary, 1072 | stats: fixResult.stats, 1073 | message: `Successfully applied ${fixResult.fixes.length} fixes to workflow "${workflow.name}"` 1074 | } 1075 | }; 1076 | } 1077 | 1078 | return { 1079 | success: true, 1080 | data: { 1081 | workflowId: workflow.id, 1082 | workflowName: workflow.name, 1083 | message: 'No fixes needed' 1084 | } 1085 | }; 1086 | 1087 | } catch (error) { 1088 | if (error instanceof z.ZodError) { 1089 | return { 1090 | success: false, 1091 | error: 'Invalid input', 1092 | details: { errors: error.errors } 1093 | }; 1094 | } 1095 | 1096 | if (error instanceof N8nApiError) { 1097 | return { 1098 | success: false, 1099 | error: getUserFriendlyErrorMessage(error), 1100 | code: error.code 1101 | }; 1102 | } 1103 | 1104 | return { 1105 | success: false, 1106 | error: error instanceof Error ? error.message : 'Unknown error occurred' 1107 | }; 1108 | } 1109 | } 1110 | 1111 | // Execution Management Handlers 1112 | 1113 | export async function handleTriggerWebhookWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 1114 | try { 1115 | const client = ensureApiConfigured(context); 1116 | const input = triggerWebhookSchema.parse(args); 1117 | 1118 | const webhookRequest: WebhookRequest = { 1119 | webhookUrl: input.webhookUrl, 1120 | httpMethod: input.httpMethod || 'POST', 1121 | data: input.data, 1122 | headers: input.headers, 1123 | waitForResponse: input.waitForResponse ?? true 1124 | }; 1125 | 1126 | const response = await client.triggerWebhook(webhookRequest); 1127 | 1128 | return { 1129 | success: true, 1130 | data: response, 1131 | message: 'Webhook triggered successfully' 1132 | }; 1133 | } catch (error) { 1134 | if (error instanceof z.ZodError) { 1135 | return { 1136 | success: false, 1137 | error: 'Invalid input', 1138 | details: { errors: error.errors } 1139 | }; 1140 | } 1141 | 1142 | if (error instanceof N8nApiError) { 1143 | // Try to extract execution context from error response 1144 | const errorData = error.details as any; 1145 | const executionId = errorData?.executionId || errorData?.id || errorData?.execution?.id; 1146 | const workflowId = errorData?.workflowId || errorData?.workflow?.id; 1147 | 1148 | // If we have execution ID, provide specific guidance with n8n_get_execution 1149 | if (executionId) { 1150 | return { 1151 | success: false, 1152 | error: formatExecutionError(executionId, workflowId), 1153 | code: error.code, 1154 | executionId, 1155 | workflowId: workflowId || undefined 1156 | }; 1157 | } 1158 | 1159 | // No execution ID available - workflow likely didn't start 1160 | // Provide guidance to check recent executions 1161 | if (error.code === 'SERVER_ERROR' || error.statusCode && error.statusCode >= 500) { 1162 | return { 1163 | success: false, 1164 | error: formatNoExecutionError(), 1165 | code: error.code 1166 | }; 1167 | } 1168 | 1169 | // For other errors (auth, validation, etc), use standard message 1170 | return { 1171 | success: false, 1172 | error: getUserFriendlyErrorMessage(error), 1173 | code: error.code, 1174 | details: error.details as Record<string, unknown> | undefined 1175 | }; 1176 | } 1177 | 1178 | return { 1179 | success: false, 1180 | error: error instanceof Error ? error.message : 'Unknown error occurred' 1181 | }; 1182 | } 1183 | } 1184 | 1185 | export async function handleGetExecution(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 1186 | try { 1187 | const client = ensureApiConfigured(context); 1188 | 1189 | // Parse and validate input with new parameters 1190 | const schema = z.object({ 1191 | id: z.string(), 1192 | // New filtering parameters 1193 | mode: z.enum(['preview', 'summary', 'filtered', 'full']).optional(), 1194 | nodeNames: z.array(z.string()).optional(), 1195 | itemsLimit: z.number().optional(), 1196 | includeInputData: z.boolean().optional(), 1197 | // Legacy parameter (backward compatibility) 1198 | includeData: z.boolean().optional() 1199 | }); 1200 | 1201 | const params = schema.parse(args); 1202 | const { id, mode, nodeNames, itemsLimit, includeInputData, includeData } = params; 1203 | 1204 | /** 1205 | * Map legacy includeData parameter to mode for backward compatibility 1206 | * 1207 | * Legacy behavior: 1208 | * - includeData: undefined -> minimal execution summary (no data) 1209 | * - includeData: false -> minimal execution summary (no data) 1210 | * - includeData: true -> full execution data 1211 | * 1212 | * New behavior mapping: 1213 | * - includeData: undefined -> no mode (minimal) 1214 | * - includeData: false -> no mode (minimal) 1215 | * - includeData: true -> mode: 'summary' (2 items per node, not full) 1216 | * 1217 | * Note: Legacy true behavior returned ALL data, which could exceed token limits. 1218 | * New behavior caps at 2 items for safety. Users can use mode: 'full' for old behavior. 1219 | */ 1220 | let effectiveMode = mode; 1221 | if (!effectiveMode && includeData !== undefined) { 1222 | effectiveMode = includeData ? 'summary' : undefined; 1223 | } 1224 | 1225 | // Determine if we need to fetch full data from API 1226 | // We fetch full data if any mode is specified (including preview) or legacy includeData is true 1227 | // Preview mode needs the data to analyze structure and generate recommendations 1228 | const fetchFullData = effectiveMode !== undefined || includeData === true; 1229 | 1230 | // Fetch execution from n8n API 1231 | const execution = await client.getExecution(id, fetchFullData); 1232 | 1233 | // If no filtering options specified, return original execution (backward compatibility) 1234 | if (!effectiveMode && !nodeNames && itemsLimit === undefined) { 1235 | return { 1236 | success: true, 1237 | data: execution 1238 | }; 1239 | } 1240 | 1241 | // Apply filtering using ExecutionProcessor 1242 | const filterOptions: ExecutionFilterOptions = { 1243 | mode: effectiveMode, 1244 | nodeNames, 1245 | itemsLimit, 1246 | includeInputData 1247 | }; 1248 | 1249 | const processedExecution = processExecution(execution, filterOptions); 1250 | 1251 | return { 1252 | success: true, 1253 | data: processedExecution 1254 | }; 1255 | } catch (error) { 1256 | if (error instanceof z.ZodError) { 1257 | return { 1258 | success: false, 1259 | error: 'Invalid input', 1260 | details: { errors: error.errors } 1261 | }; 1262 | } 1263 | 1264 | if (error instanceof N8nApiError) { 1265 | return { 1266 | success: false, 1267 | error: getUserFriendlyErrorMessage(error), 1268 | code: error.code 1269 | }; 1270 | } 1271 | 1272 | return { 1273 | success: false, 1274 | error: error instanceof Error ? error.message : 'Unknown error occurred' 1275 | }; 1276 | } 1277 | } 1278 | 1279 | export async function handleListExecutions(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 1280 | try { 1281 | const client = ensureApiConfigured(context); 1282 | const input = listExecutionsSchema.parse(args || {}); 1283 | 1284 | const response = await client.listExecutions({ 1285 | limit: input.limit || 100, 1286 | cursor: input.cursor, 1287 | workflowId: input.workflowId, 1288 | projectId: input.projectId, 1289 | status: input.status as ExecutionStatus | undefined, 1290 | includeData: input.includeData || false 1291 | }); 1292 | 1293 | return { 1294 | success: true, 1295 | data: { 1296 | executions: response.data, 1297 | returned: response.data.length, 1298 | nextCursor: response.nextCursor, 1299 | hasMore: !!response.nextCursor, 1300 | ...(response.nextCursor ? { 1301 | _note: "More executions available. Use cursor to get next page." 1302 | } : {}) 1303 | } 1304 | }; 1305 | } catch (error) { 1306 | if (error instanceof z.ZodError) { 1307 | return { 1308 | success: false, 1309 | error: 'Invalid input', 1310 | details: { errors: error.errors } 1311 | }; 1312 | } 1313 | 1314 | if (error instanceof N8nApiError) { 1315 | return { 1316 | success: false, 1317 | error: getUserFriendlyErrorMessage(error), 1318 | code: error.code 1319 | }; 1320 | } 1321 | 1322 | return { 1323 | success: false, 1324 | error: error instanceof Error ? error.message : 'Unknown error occurred' 1325 | }; 1326 | } 1327 | } 1328 | 1329 | export async function handleDeleteExecution(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { 1330 | try { 1331 | const client = ensureApiConfigured(context); 1332 | const { id } = z.object({ id: z.string() }).parse(args); 1333 | 1334 | await client.deleteExecution(id); 1335 | 1336 | return { 1337 | success: true, 1338 | message: `Execution ${id} deleted successfully` 1339 | }; 1340 | } catch (error) { 1341 | if (error instanceof z.ZodError) { 1342 | return { 1343 | success: false, 1344 | error: 'Invalid input', 1345 | details: { errors: error.errors } 1346 | }; 1347 | } 1348 | 1349 | if (error instanceof N8nApiError) { 1350 | return { 1351 | success: false, 1352 | error: getUserFriendlyErrorMessage(error), 1353 | code: error.code 1354 | }; 1355 | } 1356 | 1357 | return { 1358 | success: false, 1359 | error: error instanceof Error ? error.message : 'Unknown error occurred' 1360 | }; 1361 | } 1362 | } 1363 | 1364 | // System Tools Handlers 1365 | 1366 | export async function handleHealthCheck(context?: InstanceContext): Promise<McpToolResponse> { 1367 | const startTime = Date.now(); 1368 | 1369 | try { 1370 | const client = ensureApiConfigured(context); 1371 | const health = await client.healthCheck(); 1372 | 1373 | // Get MCP version from package.json 1374 | const packageJson = require('../../package.json'); 1375 | const mcpVersion = packageJson.version; 1376 | const supportedN8nVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, ''); 1377 | 1378 | // Check npm for latest version (async, non-blocking) 1379 | const versionCheck = await checkNpmVersion(); 1380 | 1381 | // Get cache metrics for performance monitoring 1382 | const cacheMetricsData = getInstanceCacheMetrics(); 1383 | 1384 | // Calculate response time 1385 | const responseTime = Date.now() - startTime; 1386 | 1387 | // Build response data 1388 | const responseData: HealthCheckResponseData = { 1389 | status: health.status, 1390 | instanceId: health.instanceId, 1391 | n8nVersion: health.n8nVersion, 1392 | features: health.features, 1393 | apiUrl: getN8nApiConfig()?.baseUrl, 1394 | mcpVersion, 1395 | supportedN8nVersion, 1396 | versionCheck: { 1397 | current: versionCheck.currentVersion, 1398 | latest: versionCheck.latestVersion, 1399 | upToDate: !versionCheck.isOutdated, 1400 | message: formatVersionMessage(versionCheck), 1401 | ...(versionCheck.updateCommand ? { updateCommand: versionCheck.updateCommand } : {}) 1402 | }, 1403 | performance: { 1404 | responseTimeMs: responseTime, 1405 | cacheHitRate: (cacheMetricsData.hits + cacheMetricsData.misses) > 0 1406 | ? ((cacheMetricsData.hits / (cacheMetricsData.hits + cacheMetricsData.misses)) * 100).toFixed(2) + '%' 1407 | : 'N/A', 1408 | cachedInstances: cacheMetricsData.size 1409 | } 1410 | }; 1411 | 1412 | // Add next steps guidance based on telemetry insights 1413 | responseData.nextSteps = [ 1414 | '• Create workflow: n8n_create_workflow', 1415 | '• List workflows: n8n_list_workflows', 1416 | '• Search nodes: search_nodes', 1417 | '• Browse templates: search_templates' 1418 | ]; 1419 | 1420 | // Add update warning if outdated 1421 | if (versionCheck.isOutdated && versionCheck.latestVersion) { 1422 | responseData.updateWarning = `⚠️ n8n-mcp v${versionCheck.latestVersion} is available (you have v${versionCheck.currentVersion}). Update recommended.`; 1423 | } 1424 | 1425 | // Track result in telemetry 1426 | telemetry.trackEvent('health_check_completed', { 1427 | success: true, 1428 | responseTimeMs: responseTime, 1429 | upToDate: !versionCheck.isOutdated, 1430 | apiConnected: true 1431 | }); 1432 | 1433 | return { 1434 | success: true, 1435 | data: responseData 1436 | }; 1437 | } catch (error) { 1438 | const responseTime = Date.now() - startTime; 1439 | 1440 | // Track failure in telemetry 1441 | telemetry.trackEvent('health_check_failed', { 1442 | success: false, 1443 | responseTimeMs: responseTime, 1444 | errorType: error instanceof N8nApiError ? error.code : 'unknown' 1445 | }); 1446 | 1447 | if (error instanceof N8nApiError) { 1448 | return { 1449 | success: false, 1450 | error: getUserFriendlyErrorMessage(error), 1451 | code: error.code, 1452 | details: { 1453 | apiUrl: getN8nApiConfig()?.baseUrl, 1454 | hint: 'Check if n8n is running and API is enabled', 1455 | troubleshooting: [ 1456 | '1. Verify n8n instance is running', 1457 | '2. Check N8N_API_URL is correct', 1458 | '3. Verify N8N_API_KEY has proper permissions', 1459 | '4. Run n8n_diagnostic for detailed analysis' 1460 | ] 1461 | } 1462 | }; 1463 | } 1464 | 1465 | return { 1466 | success: false, 1467 | error: error instanceof Error ? error.message : 'Unknown error occurred' 1468 | }; 1469 | } 1470 | } 1471 | 1472 | export async function handleListAvailableTools(context?: InstanceContext): Promise<McpToolResponse> { 1473 | const tools = [ 1474 | { 1475 | category: 'Workflow Management', 1476 | tools: [ 1477 | { name: 'n8n_create_workflow', description: 'Create new workflows' }, 1478 | { name: 'n8n_get_workflow', description: 'Get workflow by ID' }, 1479 | { name: 'n8n_get_workflow_details', description: 'Get detailed workflow info with stats' }, 1480 | { name: 'n8n_get_workflow_structure', description: 'Get simplified workflow structure' }, 1481 | { name: 'n8n_get_workflow_minimal', description: 'Get minimal workflow info' }, 1482 | { name: 'n8n_update_workflow', description: 'Update existing workflows' }, 1483 | { name: 'n8n_delete_workflow', description: 'Delete workflows' }, 1484 | { name: 'n8n_list_workflows', description: 'List workflows with filters' }, 1485 | { name: 'n8n_validate_workflow', description: 'Validate workflow from n8n instance' }, 1486 | { name: 'n8n_autofix_workflow', description: 'Automatically fix common workflow errors' } 1487 | ] 1488 | }, 1489 | { 1490 | category: 'Execution Management', 1491 | tools: [ 1492 | { name: 'n8n_trigger_webhook_workflow', description: 'Trigger workflows via webhook' }, 1493 | { name: 'n8n_get_execution', description: 'Get execution details' }, 1494 | { name: 'n8n_list_executions', description: 'List executions with filters' }, 1495 | { name: 'n8n_delete_execution', description: 'Delete execution records' } 1496 | ] 1497 | }, 1498 | { 1499 | category: 'System', 1500 | tools: [ 1501 | { name: 'n8n_health_check', description: 'Check API connectivity' }, 1502 | { name: 'n8n_list_available_tools', description: 'List all available tools' } 1503 | ] 1504 | } 1505 | ]; 1506 | 1507 | const config = getN8nApiConfig(); 1508 | const apiConfigured = config !== null; 1509 | 1510 | return { 1511 | success: true, 1512 | data: { 1513 | tools, 1514 | apiConfigured, 1515 | configuration: config ? { 1516 | apiUrl: config.baseUrl, 1517 | timeout: config.timeout, 1518 | maxRetries: config.maxRetries 1519 | } : null, 1520 | limitations: [ 1521 | 'Cannot activate/deactivate workflows via API', 1522 | 'Cannot execute workflows directly (must use webhooks)', 1523 | 'Cannot stop running executions', 1524 | 'Tags and credentials have limited API support' 1525 | ] 1526 | } 1527 | }; 1528 | } 1529 | 1530 | // Environment-aware debugging helpers 1531 | 1532 | /** 1533 | * Detect cloud platform from environment variables 1534 | * Returns platform name or null if not in cloud 1535 | */ 1536 | function detectCloudPlatform(): string | null { 1537 | if (process.env.RAILWAY_ENVIRONMENT) return 'railway'; 1538 | if (process.env.RENDER) return 'render'; 1539 | if (process.env.FLY_APP_NAME) return 'fly'; 1540 | if (process.env.HEROKU_APP_NAME) return 'heroku'; 1541 | if (process.env.AWS_EXECUTION_ENV) return 'aws'; 1542 | if (process.env.KUBERNETES_SERVICE_HOST) return 'kubernetes'; 1543 | if (process.env.GOOGLE_CLOUD_PROJECT) return 'gcp'; 1544 | if (process.env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure'; 1545 | return null; 1546 | } 1547 | 1548 | /** 1549 | * Get mode-specific debugging suggestions 1550 | */ 1551 | function getModeSpecificDebug(mcpMode: string) { 1552 | if (mcpMode === 'http') { 1553 | const port = process.env.MCP_PORT || process.env.PORT || 3000; 1554 | return { 1555 | mode: 'HTTP Server', 1556 | port, 1557 | authTokenConfigured: !!(process.env.MCP_AUTH_TOKEN || process.env.AUTH_TOKEN), 1558 | corsEnabled: true, 1559 | serverUrl: `http://localhost:${port}`, 1560 | healthCheckUrl: `http://localhost:${port}/health`, 1561 | troubleshooting: [ 1562 | `1. Test server health: curl http://localhost:${port}/health`, 1563 | '2. Check browser console for CORS errors', 1564 | '3. Verify MCP_AUTH_TOKEN or AUTH_TOKEN if authentication enabled', 1565 | `4. Ensure port ${port} is not in use: lsof -i :${port} (macOS/Linux) or netstat -ano | findstr :${port} (Windows)`, 1566 | '5. Check firewall settings for port access', 1567 | '6. Review server logs for connection errors' 1568 | ], 1569 | commonIssues: [ 1570 | 'CORS policy blocking browser requests', 1571 | 'Port already in use by another application', 1572 | 'Authentication token mismatch', 1573 | 'Network firewall blocking connections' 1574 | ] 1575 | }; 1576 | } else { 1577 | // stdio mode 1578 | const configLocation = process.platform === 'darwin' 1579 | ? '~/Library/Application Support/Claude/claude_desktop_config.json' 1580 | : process.platform === 'win32' 1581 | ? '%APPDATA%\\Claude\\claude_desktop_config.json' 1582 | : '~/.config/Claude/claude_desktop_config.json'; 1583 | 1584 | return { 1585 | mode: 'Standard I/O (Claude Desktop)', 1586 | configLocation, 1587 | troubleshooting: [ 1588 | '1. Verify Claude Desktop config file exists and is valid JSON', 1589 | '2. Check MCP server entry: {"mcpServers": {"n8n": {"command": "npx", "args": ["-y", "n8n-mcp"]}}}', 1590 | '3. Restart Claude Desktop after config changes', 1591 | '4. Check Claude Desktop logs for startup errors', 1592 | '5. Test npx can run: npx -y n8n-mcp --version', 1593 | '6. Verify executable permissions if using local installation' 1594 | ], 1595 | commonIssues: [ 1596 | 'Invalid JSON in claude_desktop_config.json', 1597 | 'Incorrect command or args in MCP server config', 1598 | 'Claude Desktop not restarted after config changes', 1599 | 'npx unable to download or run package', 1600 | 'Missing execute permissions on local binary' 1601 | ] 1602 | }; 1603 | } 1604 | } 1605 | 1606 | /** 1607 | * Get Docker-specific debugging suggestions 1608 | */ 1609 | function getDockerDebug(isDocker: boolean) { 1610 | if (!isDocker) return null; 1611 | 1612 | return { 1613 | containerDetected: true, 1614 | troubleshooting: [ 1615 | '1. Verify volume mounts for data/nodes.db', 1616 | '2. Check network connectivity to n8n instance', 1617 | '3. Ensure ports are correctly mapped', 1618 | '4. Review container logs: docker logs <container-name>', 1619 | '5. Verify environment variables passed to container', 1620 | '6. Check IS_DOCKER=true is set correctly' 1621 | ], 1622 | commonIssues: [ 1623 | 'Volume mount not persisting database', 1624 | 'Network isolation preventing n8n API access', 1625 | 'Port mapping conflicts', 1626 | 'Missing environment variables in container' 1627 | ] 1628 | }; 1629 | } 1630 | 1631 | /** 1632 | * Get cloud platform-specific suggestions 1633 | */ 1634 | function getCloudPlatformDebug(cloudPlatform: string | null) { 1635 | if (!cloudPlatform) return null; 1636 | 1637 | const platformGuides: Record<string, CloudPlatformGuide> = { 1638 | railway: { 1639 | name: 'Railway', 1640 | troubleshooting: [ 1641 | '1. Check Railway environment variables are set', 1642 | '2. Verify deployment logs in Railway dashboard', 1643 | '3. Ensure PORT matches Railway assigned port (automatic)', 1644 | '4. Check networking configuration for external access' 1645 | ] 1646 | }, 1647 | render: { 1648 | name: 'Render', 1649 | troubleshooting: [ 1650 | '1. Verify Render environment variables', 1651 | '2. Check Render logs for startup errors', 1652 | '3. Ensure health check endpoint is responding', 1653 | '4. Verify instance type has sufficient resources' 1654 | ] 1655 | }, 1656 | fly: { 1657 | name: 'Fly.io', 1658 | troubleshooting: [ 1659 | '1. Check Fly.io logs: flyctl logs', 1660 | '2. Verify fly.toml configuration', 1661 | '3. Ensure volumes are properly mounted', 1662 | '4. Check app status: flyctl status' 1663 | ] 1664 | }, 1665 | heroku: { 1666 | name: 'Heroku', 1667 | troubleshooting: [ 1668 | '1. Check Heroku logs: heroku logs --tail', 1669 | '2. Verify Procfile configuration', 1670 | '3. Ensure dynos are running: heroku ps', 1671 | '4. Check environment variables: heroku config' 1672 | ] 1673 | }, 1674 | kubernetes: { 1675 | name: 'Kubernetes', 1676 | troubleshooting: [ 1677 | '1. Check pod logs: kubectl logs <pod-name>', 1678 | '2. Verify service and ingress configuration', 1679 | '3. Check persistent volume claims', 1680 | '4. Verify resource limits and requests' 1681 | ] 1682 | }, 1683 | aws: { 1684 | name: 'AWS', 1685 | troubleshooting: [ 1686 | '1. Check CloudWatch logs', 1687 | '2. Verify IAM roles and permissions', 1688 | '3. Check security groups and networking', 1689 | '4. Verify environment variables in service config' 1690 | ] 1691 | } 1692 | }; 1693 | 1694 | return platformGuides[cloudPlatform] || { 1695 | name: cloudPlatform.toUpperCase(), 1696 | troubleshooting: [ 1697 | '1. Check cloud platform logs', 1698 | '2. Verify environment variables are set', 1699 | '3. Check networking and port configuration', 1700 | '4. Review platform-specific documentation' 1701 | ] 1702 | }; 1703 | } 1704 | 1705 | // Handler: n8n_diagnostic 1706 | export async function handleDiagnostic(request: any, context?: InstanceContext): Promise<McpToolResponse> { 1707 | const startTime = Date.now(); 1708 | const verbose = request.params?.arguments?.verbose || false; 1709 | 1710 | // Detect environment for targeted debugging 1711 | const mcpMode = process.env.MCP_MODE || 'stdio'; 1712 | const isDocker = process.env.IS_DOCKER === 'true'; 1713 | const cloudPlatform = detectCloudPlatform(); 1714 | 1715 | // Check environment variables 1716 | const envVars = { 1717 | N8N_API_URL: process.env.N8N_API_URL || null, 1718 | N8N_API_KEY: process.env.N8N_API_KEY ? '***configured***' : null, 1719 | NODE_ENV: process.env.NODE_ENV || 'production', 1720 | MCP_MODE: mcpMode, 1721 | isDocker, 1722 | cloudPlatform, 1723 | nodeVersion: process.version, 1724 | platform: process.platform 1725 | }; 1726 | 1727 | // Check API configuration 1728 | const apiConfig = getN8nApiConfig(); 1729 | const apiConfigured = apiConfig !== null; 1730 | const apiClient = getN8nApiClient(context); 1731 | 1732 | // Test API connectivity if configured 1733 | let apiStatus = { 1734 | configured: apiConfigured, 1735 | connected: false, 1736 | error: null as string | null, 1737 | version: null as string | null 1738 | }; 1739 | 1740 | if (apiClient) { 1741 | try { 1742 | const health = await apiClient.healthCheck(); 1743 | apiStatus.connected = true; 1744 | apiStatus.version = health.n8nVersion || 'unknown'; 1745 | } catch (error) { 1746 | apiStatus.error = error instanceof Error ? error.message : 'Unknown error'; 1747 | } 1748 | } 1749 | 1750 | // Check which tools are available 1751 | const documentationTools = 22; // Base documentation tools 1752 | const managementTools = apiConfigured ? 16 : 0; 1753 | const totalTools = documentationTools + managementTools; 1754 | 1755 | // Check npm version 1756 | const versionCheck = await checkNpmVersion(); 1757 | 1758 | // Get performance metrics 1759 | const cacheMetricsData = getInstanceCacheMetrics(); 1760 | const responseTime = Date.now() - startTime; 1761 | 1762 | // Build diagnostic report 1763 | const diagnostic: DiagnosticResponseData = { 1764 | timestamp: new Date().toISOString(), 1765 | environment: envVars, 1766 | apiConfiguration: { 1767 | configured: apiConfigured, 1768 | status: apiStatus, 1769 | config: apiConfig ? { 1770 | baseUrl: apiConfig.baseUrl, 1771 | timeout: apiConfig.timeout, 1772 | maxRetries: apiConfig.maxRetries 1773 | } : null 1774 | }, 1775 | versionInfo: { 1776 | current: versionCheck.currentVersion, 1777 | latest: versionCheck.latestVersion, 1778 | upToDate: !versionCheck.isOutdated, 1779 | message: formatVersionMessage(versionCheck), 1780 | ...(versionCheck.updateCommand ? { updateCommand: versionCheck.updateCommand } : {}) 1781 | }, 1782 | toolsAvailability: { 1783 | documentationTools: { 1784 | count: documentationTools, 1785 | enabled: true, 1786 | description: 'Always available - node info, search, validation, etc.' 1787 | }, 1788 | managementTools: { 1789 | count: managementTools, 1790 | enabled: apiConfigured, 1791 | description: apiConfigured ? 1792 | 'Management tools are ENABLED - create, update, execute workflows' : 1793 | 'Management tools are DISABLED - configure N8N_API_URL and N8N_API_KEY to enable' 1794 | }, 1795 | totalAvailable: totalTools 1796 | }, 1797 | performance: { 1798 | diagnosticResponseTimeMs: responseTime, 1799 | cacheHitRate: (cacheMetricsData.hits + cacheMetricsData.misses) > 0 1800 | ? ((cacheMetricsData.hits / (cacheMetricsData.hits + cacheMetricsData.misses)) * 100).toFixed(2) + '%' 1801 | : 'N/A', 1802 | cachedInstances: cacheMetricsData.size 1803 | }, 1804 | modeSpecificDebug: getModeSpecificDebug(mcpMode) 1805 | }; 1806 | 1807 | // Enhanced guidance based on telemetry insights 1808 | if (apiConfigured && apiStatus.connected) { 1809 | // API is working - provide next steps 1810 | diagnostic.nextSteps = { 1811 | message: '✓ API connected! Here\'s what you can do:', 1812 | recommended: [ 1813 | { 1814 | action: 'n8n_list_workflows', 1815 | description: 'See your existing workflows', 1816 | timing: 'Fast (6 seconds median)' 1817 | }, 1818 | { 1819 | action: 'n8n_create_workflow', 1820 | description: 'Create a new workflow', 1821 | timing: 'Typically 6-14 minutes to build' 1822 | }, 1823 | { 1824 | action: 'search_nodes', 1825 | description: 'Discover available nodes', 1826 | timing: 'Fast - explore 500+ nodes' 1827 | }, 1828 | { 1829 | action: 'search_templates', 1830 | description: 'Browse pre-built workflows', 1831 | timing: 'Find examples quickly' 1832 | } 1833 | ], 1834 | tips: [ 1835 | '82% of users start creating workflows after diagnostics - you\'re ready to go!', 1836 | 'Most common first action: n8n_update_partial_workflow (managing existing workflows)', 1837 | 'Use n8n_validate_workflow before deploying to catch issues early' 1838 | ] 1839 | }; 1840 | } else if (apiConfigured && !apiStatus.connected) { 1841 | // API configured but not connecting - troubleshooting 1842 | diagnostic.troubleshooting = { 1843 | issue: '⚠️ API configured but connection failed', 1844 | error: apiStatus.error, 1845 | steps: [ 1846 | '1. Verify n8n instance is running and accessible', 1847 | '2. Check N8N_API_URL is correct (currently: ' + apiConfig?.baseUrl + ')', 1848 | '3. Test URL in browser: ' + apiConfig?.baseUrl + '/healthz', 1849 | '4. Verify N8N_API_KEY has proper permissions', 1850 | '5. Check firewall/network settings if using remote n8n', 1851 | '6. Try running n8n_health_check again after fixes' 1852 | ], 1853 | commonIssues: [ 1854 | 'Wrong port number in N8N_API_URL', 1855 | 'API key doesn\'t have sufficient permissions', 1856 | 'n8n instance not running or crashed', 1857 | 'Network firewall blocking connection' 1858 | ], 1859 | documentation: 'https://github.com/czlonkowski/n8n-mcp?tab=readme-ov-file#n8n-management-tools-optional---requires-api-configuration' 1860 | }; 1861 | } else { 1862 | // API not configured - setup guidance 1863 | diagnostic.setupGuide = { 1864 | message: 'n8n API not configured. You can still use documentation tools!', 1865 | whatYouCanDoNow: { 1866 | documentation: [ 1867 | { 1868 | tool: 'search_nodes', 1869 | description: 'Search 500+ n8n nodes', 1870 | example: 'search_nodes({query: "slack"})' 1871 | }, 1872 | { 1873 | tool: 'get_node_essentials', 1874 | description: 'Get node configuration details', 1875 | example: 'get_node_essentials({nodeType: "nodes-base.httpRequest"})' 1876 | }, 1877 | { 1878 | tool: 'search_templates', 1879 | description: 'Browse workflow templates', 1880 | example: 'search_templates({query: "chatbot"})' 1881 | }, 1882 | { 1883 | tool: 'validate_workflow', 1884 | description: 'Validate workflow JSON', 1885 | example: 'validate_workflow({workflow: {...}})' 1886 | } 1887 | ], 1888 | note: '22 documentation tools available without API configuration' 1889 | }, 1890 | whatYouCannotDo: [ 1891 | '✗ Create/update workflows in n8n instance', 1892 | '✗ List your workflows', 1893 | '✗ Execute workflows', 1894 | '✗ View execution results' 1895 | ], 1896 | howToEnable: { 1897 | steps: [ 1898 | '1. Get your n8n API key: [Your n8n instance]/settings/api', 1899 | '2. Set environment variables:', 1900 | ' N8N_API_URL=https://your-n8n-instance.com', 1901 | ' N8N_API_KEY=your_api_key_here', 1902 | '3. Restart the MCP server', 1903 | '4. Run n8n_diagnostic again to verify', 1904 | '5. All 38 tools will be available!' 1905 | ], 1906 | documentation: 'https://github.com/czlonkowski/n8n-mcp?tab=readme-ov-file#n8n-management-tools-optional---requires-api-configuration' 1907 | } 1908 | }; 1909 | } 1910 | 1911 | // Add version warning if outdated 1912 | if (versionCheck.isOutdated && versionCheck.latestVersion) { 1913 | diagnostic.updateWarning = { 1914 | message: `⚠️ Update available: v${versionCheck.currentVersion} → v${versionCheck.latestVersion}`, 1915 | command: versionCheck.updateCommand, 1916 | benefits: [ 1917 | 'Latest bug fixes and improvements', 1918 | 'New features and tools', 1919 | 'Better performance and reliability' 1920 | ] 1921 | }; 1922 | } 1923 | 1924 | // Add Docker-specific debugging if in container 1925 | const dockerDebug = getDockerDebug(isDocker); 1926 | if (dockerDebug) { 1927 | diagnostic.dockerDebug = dockerDebug; 1928 | } 1929 | 1930 | // Add cloud platform-specific debugging if detected 1931 | const cloudDebug = getCloudPlatformDebug(cloudPlatform); 1932 | if (cloudDebug) { 1933 | diagnostic.cloudPlatformDebug = cloudDebug; 1934 | } 1935 | 1936 | // Add verbose debug info if requested 1937 | if (verbose) { 1938 | diagnostic.debug = { 1939 | processEnv: Object.keys(process.env).filter(key => 1940 | key.startsWith('N8N_') || key.startsWith('MCP_') 1941 | ), 1942 | nodeVersion: process.version, 1943 | platform: process.platform, 1944 | workingDirectory: process.cwd(), 1945 | cacheMetrics: cacheMetricsData 1946 | }; 1947 | } 1948 | 1949 | // Track diagnostic usage with result data 1950 | telemetry.trackEvent('diagnostic_completed', { 1951 | success: true, 1952 | apiConfigured, 1953 | apiConnected: apiStatus.connected, 1954 | toolsAvailable: totalTools, 1955 | responseTimeMs: responseTime, 1956 | upToDate: !versionCheck.isOutdated, 1957 | verbose 1958 | }); 1959 | 1960 | return { 1961 | success: true, 1962 | data: diagnostic 1963 | }; 1964 | } 1965 | ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-comprehensive.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; 2 | import { WorkflowValidator } from '@/services/workflow-validator'; 3 | import { NodeRepository } from '@/database/node-repository'; 4 | import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; 5 | import { ExpressionValidator } from '@/services/expression-validator'; 6 | import { createWorkflow } from '@tests/utils/builders/workflow.builder'; 7 | import type { WorkflowNode, Workflow } from '@/types/n8n-api'; 8 | 9 | // Mock dependencies 10 | vi.mock('@/database/node-repository'); 11 | vi.mock('@/services/enhanced-config-validator'); 12 | vi.mock('@/services/expression-validator'); 13 | vi.mock('@/utils/logger'); 14 | 15 | describe('WorkflowValidator - Comprehensive Tests', () => { 16 | let validator: WorkflowValidator; 17 | let mockNodeRepository: NodeRepository; 18 | let mockEnhancedConfigValidator: typeof EnhancedConfigValidator; 19 | 20 | beforeEach(() => { 21 | vi.clearAllMocks(); 22 | 23 | // Create mock instances 24 | mockNodeRepository = new NodeRepository({} as any) as any; 25 | mockEnhancedConfigValidator = EnhancedConfigValidator as any; 26 | 27 | // Ensure the mock repository has all necessary methods 28 | if (!mockNodeRepository.getAllNodes) { 29 | mockNodeRepository.getAllNodes = vi.fn(); 30 | } 31 | if (!mockNodeRepository.getNode) { 32 | mockNodeRepository.getNode = vi.fn(); 33 | } 34 | 35 | // Mock common node types data 36 | const nodeTypes: Record<string, any> = { 37 | 'nodes-base.webhook': { 38 | type: 'nodes-base.webhook', 39 | displayName: 'Webhook', 40 | package: 'n8n-nodes-base', 41 | version: 2, 42 | isVersioned: true, 43 | properties: [], 44 | category: 'trigger' 45 | }, 46 | 'nodes-base.httpRequest': { 47 | type: 'nodes-base.httpRequest', 48 | displayName: 'HTTP Request', 49 | package: 'n8n-nodes-base', 50 | version: 4, 51 | isVersioned: true, 52 | properties: [], 53 | category: 'network' 54 | }, 55 | 'nodes-base.set': { 56 | type: 'nodes-base.set', 57 | displayName: 'Set', 58 | package: 'n8n-nodes-base', 59 | version: 3, 60 | isVersioned: true, 61 | properties: [], 62 | category: 'data' 63 | }, 64 | 'nodes-base.code': { 65 | type: 'nodes-base.code', 66 | displayName: 'Code', 67 | package: 'n8n-nodes-base', 68 | version: 2, 69 | isVersioned: true, 70 | properties: [], 71 | category: 'code' 72 | }, 73 | 'nodes-base.manualTrigger': { 74 | type: 'nodes-base.manualTrigger', 75 | displayName: 'Manual Trigger', 76 | package: 'n8n-nodes-base', 77 | version: 1, 78 | isVersioned: true, 79 | properties: [], 80 | category: 'trigger' 81 | }, 82 | 'nodes-base.if': { 83 | type: 'nodes-base.if', 84 | displayName: 'IF', 85 | package: 'n8n-nodes-base', 86 | version: 2, 87 | isVersioned: true, 88 | properties: [], 89 | category: 'logic' 90 | }, 91 | 'nodes-base.slack': { 92 | type: 'nodes-base.slack', 93 | displayName: 'Slack', 94 | package: 'n8n-nodes-base', 95 | version: 2, 96 | isVersioned: true, 97 | properties: [], 98 | category: 'communication' 99 | }, 100 | 'nodes-base.googleSheets': { 101 | type: 'nodes-base.googleSheets', 102 | displayName: 'Google Sheets', 103 | package: 'n8n-nodes-base', 104 | version: 4, 105 | isVersioned: true, 106 | properties: [], 107 | category: 'data' 108 | }, 109 | 'nodes-langchain.agent': { 110 | type: 'nodes-langchain.agent', 111 | displayName: 'AI Agent', 112 | package: '@n8n/n8n-nodes-langchain', 113 | version: 1, 114 | isVersioned: true, 115 | properties: [], 116 | isAITool: true, 117 | category: 'ai' 118 | }, 119 | 'nodes-base.postgres': { 120 | type: 'nodes-base.postgres', 121 | displayName: 'Postgres', 122 | package: 'n8n-nodes-base', 123 | version: 2, 124 | isVersioned: true, 125 | properties: [], 126 | category: 'database' 127 | }, 128 | 'community.customNode': { 129 | type: 'community.customNode', 130 | displayName: 'Custom Node', 131 | package: 'n8n-nodes-custom', 132 | version: 1, 133 | isVersioned: false, 134 | properties: [], 135 | isAITool: false, 136 | category: 'custom' 137 | } 138 | }; 139 | 140 | // Set up default mock behaviors 141 | vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => { 142 | // Handle normalization for custom nodes 143 | if (nodeType === 'n8n-nodes-custom.customNode') { 144 | return { 145 | type: 'n8n-nodes-custom.customNode', 146 | displayName: 'Custom Node', 147 | package: 'n8n-nodes-custom', 148 | version: 1, 149 | isVersioned: false, 150 | properties: [], 151 | isAITool: false 152 | }; 153 | } 154 | 155 | return nodeTypes[nodeType] || null; 156 | }); 157 | 158 | // Mock getAllNodes for NodeSimilarityService 159 | vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes)); 160 | 161 | vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ 162 | errors: [], 163 | warnings: [], 164 | suggestions: [], 165 | mode: 'operation' as const, 166 | valid: true, 167 | visibleProperties: [], 168 | hiddenProperties: [] 169 | } as any); 170 | 171 | vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ 172 | valid: true, 173 | errors: [], 174 | warnings: [], 175 | usedVariables: new Set(), 176 | usedNodes: new Set() 177 | }); 178 | 179 | // Create validator instance 180 | validator = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator); 181 | }); 182 | 183 | describe('validateWorkflow', () => { 184 | it('should validate a minimal valid workflow', async () => { 185 | const workflow = createWorkflow('Test Workflow') 186 | .addWebhookNode({ name: 'Webhook' }) 187 | .build(); 188 | 189 | const result = await validator.validateWorkflow(workflow as any); 190 | 191 | expect(result.valid).toBe(true); 192 | expect(result.errors).toHaveLength(0); 193 | expect(result.statistics.totalNodes).toBe(1); 194 | expect(result.statistics.enabledNodes).toBe(1); 195 | expect(result.statistics.triggerNodes).toBe(1); 196 | }); 197 | 198 | it('should validate a workflow with all options disabled', async () => { 199 | const workflow = createWorkflow('Test Workflow') 200 | .addWebhookNode({ name: 'Webhook' }) 201 | .build(); 202 | 203 | const result = await validator.validateWorkflow(workflow as any, { 204 | validateNodes: false, 205 | validateConnections: false, 206 | validateExpressions: false 207 | }); 208 | 209 | expect(result.valid).toBe(true); 210 | expect(mockNodeRepository.getNode).not.toHaveBeenCalled(); 211 | expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled(); 212 | }); 213 | 214 | it('should handle validation errors gracefully', async () => { 215 | const workflow = createWorkflow('Test Workflow') 216 | .addWebhookNode({ name: 'Webhook' }) 217 | .build(); 218 | 219 | // Make the validation throw an error 220 | vi.mocked(mockNodeRepository.getNode).mockImplementation(() => { 221 | throw new Error('Database error'); 222 | }); 223 | 224 | const result = await validator.validateWorkflow(workflow as any); 225 | 226 | expect(result.valid).toBe(false); 227 | expect(result.errors.length).toBeGreaterThan(0); 228 | expect(result.errors.some(e => e.message.includes('Database error'))).toBe(true); 229 | }); 230 | 231 | it('should use different validation profiles', async () => { 232 | const workflow = createWorkflow('Test Workflow') 233 | .addWebhookNode({ name: 'Webhook' }) 234 | .build(); 235 | 236 | const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict'] as const; 237 | 238 | for (const profile of profiles) { 239 | const result = await validator.validateWorkflow(workflow as any, { profile }); 240 | expect(result).toBeDefined(); 241 | expect(mockEnhancedConfigValidator.validateWithMode).toHaveBeenCalledWith( 242 | expect.any(String), 243 | expect.any(Object), 244 | expect.any(Array), 245 | 'operation', 246 | profile 247 | ); 248 | } 249 | }); 250 | }); 251 | 252 | describe('validateWorkflowStructure', () => { 253 | it('should error when nodes array is missing', async () => { 254 | const workflow = { connections: {} } as any; 255 | 256 | const result = await validator.validateWorkflow(workflow as any); 257 | 258 | expect(result.valid).toBe(false); 259 | expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true); 260 | }); 261 | 262 | it('should error when connections object is missing', async () => { 263 | const workflow = { nodes: [] } as any; 264 | 265 | const result = await validator.validateWorkflow(workflow as any); 266 | 267 | expect(result.valid).toBe(false); 268 | expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true); 269 | }); 270 | 271 | it('should warn when workflow has no nodes', async () => { 272 | const workflow = { nodes: [], connections: {} } as any; 273 | 274 | const result = await validator.validateWorkflow(workflow as any); 275 | 276 | expect(result.valid).toBe(true); // Empty workflows are valid but get a warning 277 | expect(result.warnings).toHaveLength(1); 278 | expect(result.warnings[0].message).toBe('Workflow is empty - no nodes defined'); 279 | }); 280 | 281 | it('should error for single non-webhook node workflow', async () => { 282 | const workflow = { 283 | nodes: [{ 284 | id: '1', 285 | name: 'Set', 286 | type: 'n8n-nodes-base.set', 287 | position: [100, 100], 288 | parameters: {} 289 | }], 290 | connections: {} 291 | } as any; 292 | 293 | const result = await validator.validateWorkflow(workflow as any); 294 | 295 | expect(result.valid).toBe(false); 296 | expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true); 297 | }); 298 | 299 | it('should warn for webhook without connections', async () => { 300 | const workflow = { 301 | nodes: [{ 302 | id: '1', 303 | name: 'Webhook', 304 | type: 'n8n-nodes-base.webhook', 305 | position: [100, 100], 306 | parameters: {}, 307 | typeVersion: 2 308 | }], 309 | connections: {} 310 | } as any; 311 | 312 | const result = await validator.validateWorkflow(workflow as any); 313 | 314 | expect(result.valid).toBe(true); 315 | expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true); 316 | }); 317 | 318 | it('should error for multi-node workflow without connections', async () => { 319 | const workflow = { 320 | nodes: [ 321 | { 322 | id: '1', 323 | name: 'Webhook', 324 | type: 'n8n-nodes-base.webhook', 325 | position: [100, 100], 326 | parameters: {} 327 | }, 328 | { 329 | id: '2', 330 | name: 'Set', 331 | type: 'n8n-nodes-base.set', 332 | position: [300, 100], 333 | parameters: {} 334 | } 335 | ], 336 | connections: {} 337 | } as any; 338 | 339 | const result = await validator.validateWorkflow(workflow as any); 340 | 341 | expect(result.valid).toBe(false); 342 | expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true); 343 | }); 344 | 345 | it('should detect duplicate node names', async () => { 346 | const workflow = { 347 | nodes: [ 348 | { 349 | id: '1', 350 | name: 'Webhook', 351 | type: 'n8n-nodes-base.webhook', 352 | position: [100, 100], 353 | parameters: {} 354 | }, 355 | { 356 | id: '2', 357 | name: 'Webhook', 358 | type: 'n8n-nodes-base.webhook', 359 | position: [300, 100], 360 | parameters: {} 361 | } 362 | ], 363 | connections: {} 364 | } as any; 365 | 366 | const result = await validator.validateWorkflow(workflow as any); 367 | 368 | expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true); 369 | }); 370 | 371 | it('should detect duplicate node IDs', async () => { 372 | const workflow = { 373 | nodes: [ 374 | { 375 | id: '1', 376 | name: 'Webhook1', 377 | type: 'n8n-nodes-base.webhook', 378 | position: [100, 100], 379 | parameters: {} 380 | }, 381 | { 382 | id: '1', 383 | name: 'Webhook2', 384 | type: 'n8n-nodes-base.webhook', 385 | position: [300, 100], 386 | parameters: {} 387 | } 388 | ], 389 | connections: {} 390 | } as any; 391 | 392 | const result = await validator.validateWorkflow(workflow as any); 393 | 394 | expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true); 395 | }); 396 | 397 | it('should count trigger nodes correctly', async () => { 398 | const workflow = { 399 | nodes: [ 400 | { 401 | id: '1', 402 | name: 'Webhook', 403 | type: 'n8n-nodes-base.webhook', 404 | position: [100, 100], 405 | parameters: {} 406 | }, 407 | { 408 | id: '2', 409 | name: 'Schedule', 410 | type: 'n8n-nodes-base.scheduleTrigger', 411 | position: [100, 300], 412 | parameters: {} 413 | }, 414 | { 415 | id: '3', 416 | name: 'Manual', 417 | type: 'n8n-nodes-base.manualTrigger', 418 | position: [100, 500], 419 | parameters: {} 420 | } 421 | ], 422 | connections: {} 423 | } as any; 424 | 425 | const result = await validator.validateWorkflow(workflow as any); 426 | 427 | expect(result.statistics.triggerNodes).toBe(3); 428 | }); 429 | 430 | it('should warn when no trigger nodes exist', async () => { 431 | const workflow = { 432 | nodes: [ 433 | { 434 | id: '1', 435 | name: 'Set', 436 | type: 'n8n-nodes-base.set', 437 | position: [100, 100], 438 | parameters: {} 439 | }, 440 | { 441 | id: '2', 442 | name: 'Code', 443 | type: 'n8n-nodes-base.code', 444 | position: [300, 100], 445 | parameters: {} 446 | } 447 | ], 448 | connections: { 449 | 'Set': { 450 | main: [[{ node: 'Code', type: 'main', index: 0 }]] 451 | } 452 | } 453 | } as any; 454 | 455 | const result = await validator.validateWorkflow(workflow as any); 456 | 457 | expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true); 458 | }); 459 | 460 | it('should not count disabled nodes in enabledNodes count', async () => { 461 | const workflow = { 462 | nodes: [ 463 | { 464 | id: '1', 465 | name: 'Webhook', 466 | type: 'n8n-nodes-base.webhook', 467 | position: [100, 100], 468 | parameters: {}, 469 | disabled: true 470 | }, 471 | { 472 | id: '2', 473 | name: 'Set', 474 | type: 'n8n-nodes-base.set', 475 | position: [300, 100], 476 | parameters: {} 477 | } 478 | ], 479 | connections: {} 480 | } as any; 481 | 482 | const result = await validator.validateWorkflow(workflow as any); 483 | 484 | expect(result.statistics.totalNodes).toBe(2); 485 | expect(result.statistics.enabledNodes).toBe(1); 486 | }); 487 | }); 488 | 489 | describe('validateAllNodes', () => { 490 | it('should skip disabled nodes', async () => { 491 | const workflow = { 492 | nodes: [ 493 | { 494 | id: '1', 495 | name: 'Webhook', 496 | type: 'n8n-nodes-base.webhook', 497 | position: [100, 100], 498 | parameters: {}, 499 | disabled: true 500 | } 501 | ], 502 | connections: {} 503 | } as any; 504 | 505 | const result = await validator.validateWorkflow(workflow as any); 506 | 507 | expect(mockNodeRepository.getNode).not.toHaveBeenCalled(); 508 | }); 509 | 510 | it('should accept both nodes-base and n8n-nodes-base prefixes as valid', async () => { 511 | // This test verifies the fix for false positives - both prefixes are valid 512 | const workflow = { 513 | nodes: [ 514 | { 515 | id: '1', 516 | name: 'Webhook', 517 | type: 'nodes-base.webhook', // This is now valid (normalized internally) 518 | position: [100, 100], 519 | parameters: {} 520 | } 521 | ], 522 | connections: {} 523 | } as any; 524 | 525 | // Mock the normalized node lookup 526 | (mockNodeRepository.getNode as any) = vi.fn((type: string) => { 527 | if (type === 'nodes-base.webhook') { 528 | return { 529 | nodeType: 'nodes-base.webhook', 530 | displayName: 'Webhook', 531 | properties: [], 532 | isVersioned: false 533 | }; 534 | } 535 | return null; 536 | }); 537 | 538 | const result = await validator.validateWorkflow(workflow as any); 539 | 540 | // Should NOT error for nodes-base prefix - it's valid! 541 | expect(result.valid).toBe(true); 542 | expect(result.errors.some(e => e.message.includes('Invalid node type'))).toBe(false); 543 | }); 544 | 545 | it.skip('should handle unknown node types with suggestions', async () => { 546 | const workflow = { 547 | nodes: [ 548 | { 549 | id: '1', 550 | name: 'HTTP', 551 | type: 'httpRequest', // Missing package prefix 552 | position: [100, 100], 553 | parameters: {} 554 | } 555 | ], 556 | connections: {} 557 | } as any; 558 | 559 | const result = await validator.validateWorkflow(workflow as any); 560 | 561 | expect(result.valid).toBe(false); 562 | expect(result.errors.some(e => e.message.includes('Unknown node type: "httpRequest"'))).toBe(true); 563 | expect(result.errors.some(e => e.message.includes('Did you mean "n8n-nodes-base.httpRequest"?'))).toBe(true); 564 | }); 565 | 566 | it('should try normalized types for n8n-nodes-base', async () => { 567 | const workflow = { 568 | nodes: [ 569 | { 570 | id: '1', 571 | name: 'Webhook', 572 | type: 'n8n-nodes-base.webhook', 573 | position: [100, 100], 574 | parameters: {} 575 | } 576 | ], 577 | connections: {} 578 | } as any; 579 | 580 | const result = await validator.validateWorkflow(workflow as any); 581 | 582 | expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook'); 583 | }); 584 | 585 | it('should validate typeVersion but skip parameter validation for langchain nodes', async () => { 586 | const workflow = { 587 | nodes: [ 588 | { 589 | id: '1', 590 | name: 'Agent', 591 | type: '@n8n/n8n-nodes-langchain.agent', 592 | typeVersion: 1, 593 | position: [100, 100], 594 | parameters: {} 595 | } 596 | ], 597 | connections: {} 598 | } as any; 599 | 600 | const result = await validator.validateWorkflow(workflow as any); 601 | 602 | // After v2.17.4 fix: Langchain nodes SHOULD call getNode for typeVersion validation 603 | // This prevents invalid typeVersion values from bypassing validation 604 | // But they skip parameter validation (handled by dedicated AI validators) 605 | expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent'); 606 | 607 | // Should not have typeVersion validation errors (other AI-specific errors may exist) 608 | const typeVersionErrors = result.errors.filter(e => e.message.includes('typeVersion')); 609 | expect(typeVersionErrors).toEqual([]); 610 | }); 611 | 612 | it('should catch invalid typeVersion for langchain nodes (v2.17.4 bug fix)', async () => { 613 | const workflow = { 614 | nodes: [ 615 | { 616 | id: '1', 617 | name: 'Agent', 618 | type: '@n8n/n8n-nodes-langchain.agent', 619 | typeVersion: 99999, // Invalid - exceeds maximum 620 | position: [100, 100], 621 | parameters: {} 622 | } 623 | ], 624 | connections: {} 625 | } as any; 626 | 627 | const result = await validator.validateWorkflow(workflow as any); 628 | 629 | // Critical: Before v2.17.4, this would pass validation but fail at runtime 630 | // After v2.17.4: Invalid typeVersion is caught during validation 631 | expect(result.valid).toBe(false); 632 | expect(result.errors.some(e => 633 | e.message.includes('typeVersion 99999 exceeds maximum') 634 | )).toBe(true); 635 | }); 636 | 637 | it('should validate typeVersion for versioned nodes', async () => { 638 | const workflow = { 639 | nodes: [ 640 | { 641 | id: '1', 642 | name: 'Webhook', 643 | type: 'n8n-nodes-base.webhook', 644 | position: [100, 100], 645 | parameters: {} 646 | // Missing typeVersion 647 | } 648 | ], 649 | connections: {} 650 | } as any; 651 | 652 | const result = await validator.validateWorkflow(workflow as any); 653 | 654 | expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true); 655 | }); 656 | 657 | it('should error for invalid typeVersion', async () => { 658 | const workflow = { 659 | nodes: [ 660 | { 661 | id: '1', 662 | name: 'Webhook', 663 | type: 'n8n-nodes-base.webhook', 664 | position: [100, 100], 665 | parameters: {}, 666 | typeVersion: 'invalid' as any 667 | } 668 | ], 669 | connections: {} 670 | } as any; 671 | 672 | const result = await validator.validateWorkflow(workflow as any); 673 | 674 | expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true); 675 | }); 676 | 677 | it('should warn for outdated typeVersion', async () => { 678 | const workflow = { 679 | nodes: [ 680 | { 681 | id: '1', 682 | name: 'Webhook', 683 | type: 'n8n-nodes-base.webhook', 684 | position: [100, 100], 685 | parameters: {}, 686 | typeVersion: 1 // Current version is 2 687 | } 688 | ], 689 | connections: {} 690 | } as any; 691 | 692 | const result = await validator.validateWorkflow(workflow as any); 693 | 694 | expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true); 695 | }); 696 | 697 | it('should error for typeVersion exceeding maximum', async () => { 698 | const workflow = { 699 | nodes: [ 700 | { 701 | id: '1', 702 | name: 'Webhook', 703 | type: 'n8n-nodes-base.webhook', 704 | position: [100, 100], 705 | parameters: {}, 706 | typeVersion: 10 // Max is 2 707 | } 708 | ], 709 | connections: {} 710 | } as any; 711 | 712 | const result = await validator.validateWorkflow(workflow as any); 713 | 714 | expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true); 715 | }); 716 | 717 | it('should add node validation errors and warnings', async () => { 718 | vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ 719 | errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }], 720 | warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }], 721 | suggestions: [], 722 | mode: 'operation' as const, 723 | valid: false, 724 | visibleProperties: [], 725 | hiddenProperties: [] 726 | } as any); 727 | 728 | const workflow = { 729 | nodes: [ 730 | { 731 | id: '1', 732 | name: 'HTTP', 733 | type: 'n8n-nodes-base.httpRequest', 734 | position: [100, 100], 735 | parameters: {}, 736 | typeVersion: 4 737 | } 738 | ], 739 | connections: {} 740 | } as any; 741 | 742 | const result = await validator.validateWorkflow(workflow as any); 743 | 744 | expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true); 745 | expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true); 746 | }); 747 | 748 | it('should handle node validation failures gracefully', async () => { 749 | vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockImplementation(() => { 750 | throw new Error('Validation error'); 751 | }); 752 | 753 | const workflow = { 754 | nodes: [ 755 | { 756 | id: '1', 757 | name: 'HTTP', 758 | type: 'n8n-nodes-base.httpRequest', 759 | position: [100, 100], 760 | parameters: {}, 761 | typeVersion: 4 762 | } 763 | ], 764 | connections: {} 765 | } as any; 766 | 767 | const result = await validator.validateWorkflow(workflow as any); 768 | 769 | expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true); 770 | }); 771 | }); 772 | 773 | describe('validateConnections', () => { 774 | it('should validate valid connections', async () => { 775 | const workflow = { 776 | nodes: [ 777 | { 778 | id: '1', 779 | name: 'Webhook', 780 | type: 'n8n-nodes-base.webhook', 781 | position: [100, 100], 782 | parameters: {} 783 | }, 784 | { 785 | id: '2', 786 | name: 'Set', 787 | type: 'n8n-nodes-base.set', 788 | position: [300, 100], 789 | parameters: {} 790 | } 791 | ], 792 | connections: { 793 | 'Webhook': { 794 | main: [[{ node: 'Set', type: 'main', index: 0 }]] 795 | } 796 | } 797 | } as any; 798 | 799 | const result = await validator.validateWorkflow(workflow as any); 800 | 801 | expect(result.statistics.validConnections).toBe(1); 802 | expect(result.statistics.invalidConnections).toBe(0); 803 | }); 804 | 805 | it('should error for connection from non-existent node', async () => { 806 | const workflow = { 807 | nodes: [ 808 | { 809 | id: '1', 810 | name: 'Webhook', 811 | type: 'n8n-nodes-base.webhook', 812 | position: [100, 100], 813 | parameters: {} 814 | } 815 | ], 816 | connections: { 817 | 'NonExistent': { 818 | main: [[{ node: 'Webhook', type: 'main', index: 0 }]] 819 | } 820 | } 821 | } as any; 822 | 823 | const result = await validator.validateWorkflow(workflow as any); 824 | 825 | expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true); 826 | expect(result.statistics.invalidConnections).toBe(1); 827 | }); 828 | 829 | it('should error when using node ID instead of name in source', async () => { 830 | const workflow = { 831 | nodes: [ 832 | { 833 | id: 'webhook-id', 834 | name: 'Webhook', 835 | type: 'n8n-nodes-base.webhook', 836 | position: [100, 100], 837 | parameters: {} 838 | }, 839 | { 840 | id: 'set-id', 841 | name: 'Set', 842 | type: 'n8n-nodes-base.set', 843 | position: [300, 100], 844 | parameters: {} 845 | } 846 | ], 847 | connections: { 848 | 'webhook-id': { // Using ID instead of name 849 | main: [[{ node: 'Set', type: 'main', index: 0 }]] 850 | } 851 | } 852 | } as any; 853 | 854 | const result = await validator.validateWorkflow(workflow as any); 855 | 856 | expect(result.errors.some(e => e.message.includes('Connection uses node ID \'webhook-id\' instead of node name \'Webhook\''))).toBe(true); 857 | }); 858 | 859 | it('should error for connection to non-existent node', async () => { 860 | const workflow = { 861 | nodes: [ 862 | { 863 | id: '1', 864 | name: 'Webhook', 865 | type: 'n8n-nodes-base.webhook', 866 | position: [100, 100], 867 | parameters: {} 868 | } 869 | ], 870 | connections: { 871 | 'Webhook': { 872 | main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] 873 | } 874 | } 875 | } as any; 876 | 877 | const result = await validator.validateWorkflow(workflow as any); 878 | 879 | expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true); 880 | expect(result.statistics.invalidConnections).toBe(1); 881 | }); 882 | 883 | it('should error when using node ID instead of name in target', async () => { 884 | const workflow = { 885 | nodes: [ 886 | { 887 | id: 'webhook-id', 888 | name: 'Webhook', 889 | type: 'n8n-nodes-base.webhook', 890 | position: [100, 100], 891 | parameters: {} 892 | }, 893 | { 894 | id: 'set-id', 895 | name: 'Set', 896 | type: 'n8n-nodes-base.set', 897 | position: [300, 100], 898 | parameters: {} 899 | } 900 | ], 901 | connections: { 902 | 'Webhook': { 903 | main: [[{ node: 'set-id', type: 'main', index: 0 }]] // Using ID instead of name 904 | } 905 | } 906 | } as any; 907 | 908 | const result = await validator.validateWorkflow(workflow as any); 909 | 910 | expect(result.errors.some(e => e.message.includes('Connection target uses node ID \'set-id\' instead of node name \'Set\''))).toBe(true); 911 | }); 912 | 913 | it('should warn for connection to disabled node', async () => { 914 | const workflow = { 915 | nodes: [ 916 | { 917 | id: '1', 918 | name: 'Webhook', 919 | type: 'n8n-nodes-base.webhook', 920 | position: [100, 100], 921 | parameters: {} 922 | }, 923 | { 924 | id: '2', 925 | name: 'Set', 926 | type: 'n8n-nodes-base.set', 927 | position: [300, 100], 928 | parameters: {}, 929 | disabled: true 930 | } 931 | ], 932 | connections: { 933 | 'Webhook': { 934 | main: [[{ node: 'Set', type: 'main', index: 0 }]] 935 | } 936 | } 937 | } as any; 938 | 939 | const result = await validator.validateWorkflow(workflow as any); 940 | 941 | expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true); 942 | }); 943 | 944 | it('should validate error outputs', async () => { 945 | const workflow = { 946 | nodes: [ 947 | { 948 | id: '1', 949 | name: 'HTTP', 950 | type: 'n8n-nodes-base.httpRequest', 951 | position: [100, 100], 952 | parameters: {} 953 | }, 954 | { 955 | id: '2', 956 | name: 'Error Handler', 957 | type: 'n8n-nodes-base.set', 958 | position: [300, 100], 959 | parameters: {} 960 | } 961 | ], 962 | connections: { 963 | 'HTTP': { 964 | error: [[{ node: 'Error Handler', type: 'main', index: 0 }]] 965 | } 966 | } 967 | } as any; 968 | 969 | const result = await validator.validateWorkflow(workflow as any); 970 | 971 | expect(result.statistics.validConnections).toBe(1); 972 | }); 973 | 974 | it('should validate AI tool connections', async () => { 975 | const workflow = { 976 | nodes: [ 977 | { 978 | id: '1', 979 | name: 'Agent', 980 | type: '@n8n/n8n-nodes-langchain.agent', 981 | position: [100, 100], 982 | parameters: {} 983 | }, 984 | { 985 | id: '2', 986 | name: 'Tool', 987 | type: 'n8n-nodes-base.httpRequest', 988 | position: [300, 100], 989 | parameters: {} 990 | } 991 | ], 992 | connections: { 993 | 'Agent': { 994 | ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]] 995 | } 996 | } 997 | } as any; 998 | 999 | const result = await validator.validateWorkflow(workflow as any); 1000 | 1001 | expect(result.statistics.validConnections).toBe(1); 1002 | }); 1003 | 1004 | it('should warn for community nodes used as AI tools', async () => { 1005 | const workflow = { 1006 | nodes: [ 1007 | { 1008 | id: '1', 1009 | name: 'Agent', 1010 | type: '@n8n/n8n-nodes-langchain.agent', 1011 | position: [100, 100], 1012 | parameters: {}, 1013 | typeVersion: 1 1014 | }, 1015 | { 1016 | id: '2', 1017 | name: 'CustomTool', 1018 | type: 'n8n-nodes-custom.customNode', 1019 | position: [300, 100], 1020 | parameters: {}, 1021 | typeVersion: 1 1022 | } 1023 | ], 1024 | connections: { 1025 | 'Agent': { 1026 | ai_tool: [[{ node: 'CustomTool', type: 'main', index: 0 }]] 1027 | } 1028 | } 1029 | } as any; 1030 | 1031 | const result = await validator.validateWorkflow(workflow as any); 1032 | 1033 | expect(result.warnings.some(w => w.message.includes('Community node "CustomTool" is being used as an AI tool'))).toBe(true); 1034 | }); 1035 | 1036 | it('should warn for orphaned nodes', async () => { 1037 | const workflow = { 1038 | nodes: [ 1039 | { 1040 | id: '1', 1041 | name: 'Webhook', 1042 | type: 'n8n-nodes-base.webhook', 1043 | position: [100, 100], 1044 | parameters: {} 1045 | }, 1046 | { 1047 | id: '2', 1048 | name: 'Set', 1049 | type: 'n8n-nodes-base.set', 1050 | position: [300, 100], 1051 | parameters: {} 1052 | }, 1053 | { 1054 | id: '3', 1055 | name: 'Orphaned', 1056 | type: 'n8n-nodes-base.code', 1057 | position: [500, 100], 1058 | parameters: {} 1059 | } 1060 | ], 1061 | connections: { 1062 | 'Webhook': { 1063 | main: [[{ node: 'Set', type: 'main', index: 0 }]] 1064 | } 1065 | } 1066 | } as any; 1067 | 1068 | const result = await validator.validateWorkflow(workflow as any); 1069 | 1070 | expect(result.warnings.some(w => w.message.includes('Node is not connected to any other nodes') && w.nodeName === 'Orphaned')).toBe(true); 1071 | }); 1072 | 1073 | it('should detect cycles in workflow', async () => { 1074 | const workflow = { 1075 | nodes: [ 1076 | { 1077 | id: '1', 1078 | name: 'Node1', 1079 | type: 'n8n-nodes-base.set', 1080 | position: [100, 100], 1081 | parameters: {} 1082 | }, 1083 | { 1084 | id: '2', 1085 | name: 'Node2', 1086 | type: 'n8n-nodes-base.set', 1087 | position: [300, 100], 1088 | parameters: {} 1089 | }, 1090 | { 1091 | id: '3', 1092 | name: 'Node3', 1093 | type: 'n8n-nodes-base.set', 1094 | position: [500, 100], 1095 | parameters: {} 1096 | } 1097 | ], 1098 | connections: { 1099 | 'Node1': { 1100 | main: [[{ node: 'Node2', type: 'main', index: 0 }]] 1101 | }, 1102 | 'Node2': { 1103 | main: [[{ node: 'Node3', type: 'main', index: 0 }]] 1104 | }, 1105 | 'Node3': { 1106 | main: [[{ node: 'Node1', type: 'main', index: 0 }]] // Creates cycle 1107 | } 1108 | } 1109 | } as any; 1110 | 1111 | const result = await validator.validateWorkflow(workflow as any); 1112 | 1113 | expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true); 1114 | }); 1115 | 1116 | it('should handle null connections properly', async () => { 1117 | const workflow = { 1118 | nodes: [ 1119 | { 1120 | id: '1', 1121 | name: 'IF', 1122 | type: 'n8n-nodes-base.if', 1123 | position: [100, 100], 1124 | parameters: {}, 1125 | typeVersion: 2 1126 | }, 1127 | { 1128 | id: '2', 1129 | name: 'True Branch', 1130 | type: 'n8n-nodes-base.set', 1131 | position: [300, 50], 1132 | parameters: {}, 1133 | typeVersion: 3 1134 | } 1135 | ], 1136 | connections: { 1137 | 'IF': { 1138 | main: [ 1139 | [{ node: 'True Branch', type: 'main', index: 0 }], 1140 | null // False branch not connected 1141 | ] 1142 | } 1143 | } 1144 | } as any; 1145 | 1146 | const result = await validator.validateWorkflow(workflow as any); 1147 | 1148 | expect(result.statistics.validConnections).toBe(1); 1149 | expect(result.valid).toBe(true); 1150 | }); 1151 | }); 1152 | 1153 | describe('validateExpressions', () => { 1154 | it('should validate expressions in node parameters', async () => { 1155 | const workflow = { 1156 | nodes: [ 1157 | { 1158 | id: '1', 1159 | name: 'Webhook', 1160 | type: 'n8n-nodes-base.webhook', 1161 | position: [100, 100], 1162 | parameters: {} 1163 | }, 1164 | { 1165 | id: '2', 1166 | name: 'Set', 1167 | type: 'n8n-nodes-base.set', 1168 | position: [300, 100], 1169 | parameters: { 1170 | values: { 1171 | string: [ 1172 | { 1173 | name: 'field', 1174 | value: '={{ $json.data }}' 1175 | } 1176 | ] 1177 | } 1178 | } 1179 | } 1180 | ], 1181 | connections: { 1182 | 'Webhook': { 1183 | main: [[{ node: 'Set', type: 'main', index: 0 }]] 1184 | } 1185 | } 1186 | } as any; 1187 | 1188 | const result = await validator.validateWorkflow(workflow as any); 1189 | 1190 | expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith( 1191 | expect.objectContaining({ values: expect.any(Object) }), 1192 | expect.objectContaining({ 1193 | availableNodes: expect.arrayContaining(['Webhook']), 1194 | currentNodeName: 'Set', 1195 | hasInputData: true 1196 | }) 1197 | ); 1198 | }); 1199 | 1200 | it('should add expression errors to result', async () => { 1201 | vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ 1202 | valid: false, 1203 | errors: ['Invalid expression syntax'], 1204 | warnings: ['Deprecated variable usage'], 1205 | usedVariables: new Set(['$json']), 1206 | usedNodes: new Set() 1207 | }); 1208 | 1209 | const workflow = { 1210 | nodes: [ 1211 | { 1212 | id: '1', 1213 | name: 'Set', 1214 | type: 'n8n-nodes-base.set', 1215 | position: [100, 100], 1216 | parameters: { 1217 | value: '={{ invalid }}' 1218 | } 1219 | } 1220 | ], 1221 | connections: {} 1222 | } as any; 1223 | 1224 | const result = await validator.validateWorkflow(workflow as any); 1225 | 1226 | expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true); 1227 | expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true); 1228 | expect(result.statistics.expressionsValidated).toBe(1); 1229 | }); 1230 | 1231 | it('should skip expression validation for disabled nodes', async () => { 1232 | const workflow = { 1233 | nodes: [ 1234 | { 1235 | id: '1', 1236 | name: 'Set', 1237 | type: 'n8n-nodes-base.set', 1238 | position: [100, 100], 1239 | parameters: { 1240 | value: '={{ $json.data }}' 1241 | }, 1242 | disabled: true 1243 | } 1244 | ], 1245 | connections: {} 1246 | } as any; 1247 | 1248 | const result = await validator.validateWorkflow(workflow as any); 1249 | 1250 | expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled(); 1251 | }); 1252 | }); 1253 | 1254 | describe('checkWorkflowPatterns', () => { 1255 | it('should suggest error handling for large workflows', async () => { 1256 | const builder = createWorkflow('Large Workflow'); 1257 | 1258 | // Add more than 3 nodes 1259 | for (let i = 0; i < 5; i++) { 1260 | builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Set${i}` }); 1261 | } 1262 | 1263 | const workflow = builder.build() as any; 1264 | 1265 | const result = await validator.validateWorkflow(workflow as any); 1266 | 1267 | expect(result.warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true); 1268 | }); 1269 | 1270 | it('should warn about long linear chains', async () => { 1271 | const builder = createWorkflow('Linear Workflow'); 1272 | 1273 | // Create a chain of 12 nodes 1274 | const nodeNames: string[] = []; 1275 | for (let i = 0; i < 12; i++) { 1276 | const nodeName = `Node${i}`; 1277 | builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: nodeName }); 1278 | nodeNames.push(nodeName); 1279 | } 1280 | 1281 | // Connect them sequentially 1282 | builder.connectSequentially(nodeNames); 1283 | 1284 | const workflow = builder.build() as any; 1285 | 1286 | const result = await validator.validateWorkflow(workflow as any); 1287 | 1288 | expect(result.warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true); 1289 | }); 1290 | 1291 | it('should warn about missing credentials', async () => { 1292 | const workflow = { 1293 | nodes: [ 1294 | { 1295 | id: '1', 1296 | name: 'Slack', 1297 | type: 'n8n-nodes-base.slack', 1298 | position: [100, 100], 1299 | parameters: {}, 1300 | credentials: { 1301 | slackApi: {} // Missing id 1302 | } 1303 | } 1304 | ], 1305 | connections: {} 1306 | } as any; 1307 | 1308 | const result = await validator.validateWorkflow(workflow as any); 1309 | 1310 | expect(result.warnings.some(w => w.message.includes('Missing credentials configuration for slackApi'))).toBe(true); 1311 | }); 1312 | 1313 | it('should warn about AI agents without tools', async () => { 1314 | const workflow = { 1315 | nodes: [ 1316 | { 1317 | id: '1', 1318 | name: 'Agent', 1319 | type: '@n8n/n8n-nodes-langchain.agent', 1320 | position: [100, 100], 1321 | parameters: {} 1322 | } 1323 | ], 1324 | connections: {} 1325 | } as any; 1326 | 1327 | const result = await validator.validateWorkflow(workflow as any); 1328 | 1329 | expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true); 1330 | }); 1331 | 1332 | it('should suggest community package setting for AI tools', async () => { 1333 | const workflow = { 1334 | nodes: [ 1335 | { 1336 | id: '1', 1337 | name: 'Agent', 1338 | type: '@n8n/n8n-nodes-langchain.agent', 1339 | position: [100, 100], 1340 | parameters: {} 1341 | }, 1342 | { 1343 | id: '2', 1344 | name: 'Tool', 1345 | type: 'n8n-nodes-base.httpRequest', 1346 | position: [300, 100], 1347 | parameters: {} 1348 | } 1349 | ], 1350 | connections: { 1351 | 'Agent': { 1352 | ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]] 1353 | } 1354 | } 1355 | } as any; 1356 | 1357 | const result = await validator.validateWorkflow(workflow as any); 1358 | 1359 | expect(result.suggestions.some(s => s.includes('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE'))).toBe(true); 1360 | }); 1361 | }); 1362 | 1363 | describe('checkNodeErrorHandling', () => { 1364 | it('should error when node-level properties are inside parameters', async () => { 1365 | const workflow = { 1366 | nodes: [ 1367 | { 1368 | id: '1', 1369 | name: 'HTTP', 1370 | type: 'n8n-nodes-base.httpRequest', 1371 | position: [100, 100], 1372 | typeVersion: 4, 1373 | parameters: { 1374 | url: 'https://api.example.com', 1375 | onError: 'continueRegularOutput', // Wrong location! 1376 | retryOnFail: true, // Wrong location! 1377 | credentials: {} // Wrong location! 1378 | } 1379 | } 1380 | ], 1381 | connections: {} 1382 | } as any; 1383 | 1384 | const result = await validator.validateWorkflow(workflow as any); 1385 | 1386 | expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true); 1387 | expect(result.errors.some(e => e.details?.fix?.includes('Move these properties from node.parameters to the node level'))).toBe(true); 1388 | }); 1389 | 1390 | it('should validate onError property values', async () => { 1391 | const workflow = { 1392 | nodes: [ 1393 | { 1394 | id: '1', 1395 | name: 'HTTP', 1396 | type: 'n8n-nodes-base.httpRequest', 1397 | position: [100, 100], 1398 | parameters: {}, 1399 | onError: 'invalidValue' as any 1400 | } 1401 | ], 1402 | connections: {} 1403 | } as any; 1404 | 1405 | const result = await validator.validateWorkflow(workflow as any); 1406 | 1407 | expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true); 1408 | }); 1409 | 1410 | it('should warn about deprecated continueOnFail', async () => { 1411 | const workflow = { 1412 | nodes: [ 1413 | { 1414 | id: '1', 1415 | name: 'HTTP', 1416 | type: 'n8n-nodes-base.httpRequest', 1417 | position: [100, 100], 1418 | parameters: {}, 1419 | continueOnFail: true 1420 | } 1421 | ], 1422 | connections: {} 1423 | } as any; 1424 | 1425 | const result = await validator.validateWorkflow(workflow as any); 1426 | 1427 | expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true); 1428 | }); 1429 | 1430 | it('should error for conflicting error handling properties', async () => { 1431 | const workflow = { 1432 | nodes: [ 1433 | { 1434 | id: '1', 1435 | name: 'HTTP', 1436 | type: 'n8n-nodes-base.httpRequest', 1437 | position: [100, 100], 1438 | parameters: {}, 1439 | continueOnFail: true, 1440 | onError: 'continueRegularOutput' 1441 | } 1442 | ], 1443 | connections: {} 1444 | } as any; 1445 | 1446 | const result = await validator.validateWorkflow(workflow as any); 1447 | 1448 | expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true); 1449 | }); 1450 | 1451 | it('should validate retry configuration', async () => { 1452 | const workflow = { 1453 | nodes: [ 1454 | { 1455 | id: '1', 1456 | name: 'HTTP', 1457 | type: 'n8n-nodes-base.httpRequest', 1458 | position: [100, 100], 1459 | parameters: {}, 1460 | retryOnFail: true, 1461 | maxTries: 'invalid' as any, 1462 | waitBetweenTries: -1000 1463 | } 1464 | ], 1465 | connections: {} 1466 | } as any; 1467 | 1468 | const result = await validator.validateWorkflow(workflow as any); 1469 | 1470 | expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true); 1471 | expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true); 1472 | }); 1473 | 1474 | it('should warn about excessive retry values', async () => { 1475 | const workflow = { 1476 | nodes: [ 1477 | { 1478 | id: '1', 1479 | name: 'HTTP', 1480 | type: 'n8n-nodes-base.httpRequest', 1481 | position: [100, 100], 1482 | parameters: {}, 1483 | retryOnFail: true, 1484 | maxTries: 15, 1485 | waitBetweenTries: 400000 1486 | } 1487 | ], 1488 | connections: {} 1489 | } as any; 1490 | 1491 | const result = await validator.validateWorkflow(workflow as any); 1492 | 1493 | expect(result.warnings.some(w => w.message.includes('maxTries is set to 15'))).toBe(true); 1494 | expect(result.warnings.some(w => w.message.includes('waitBetweenTries is set to 400000ms'))).toBe(true); 1495 | }); 1496 | 1497 | it('should warn about retryOnFail without maxTries', async () => { 1498 | const workflow = { 1499 | nodes: [ 1500 | { 1501 | id: '1', 1502 | name: 'HTTP', 1503 | type: 'n8n-nodes-base.httpRequest', 1504 | position: [100, 100], 1505 | parameters: {}, 1506 | retryOnFail: true 1507 | } 1508 | ], 1509 | connections: {} 1510 | } as any; 1511 | 1512 | const result = await validator.validateWorkflow(workflow as any); 1513 | 1514 | expect(result.warnings.some(w => w.message.includes('retryOnFail is enabled but maxTries is not specified'))).toBe(true); 1515 | }); 1516 | 1517 | it('should validate other node-level properties', async () => { 1518 | const workflow = { 1519 | nodes: [ 1520 | { 1521 | id: '1', 1522 | name: 'Set', 1523 | type: 'n8n-nodes-base.set', 1524 | position: [100, 100], 1525 | parameters: {}, 1526 | typeVersion: 3, 1527 | alwaysOutputData: 'invalid' as any, 1528 | executeOnce: 'invalid' as any, 1529 | disabled: 'invalid' as any, 1530 | notesInFlow: 'invalid' as any, 1531 | notes: 123 as any 1532 | } 1533 | ], 1534 | connections: {} 1535 | } as any; 1536 | 1537 | const result = await validator.validateWorkflow(workflow as any); 1538 | 1539 | 1540 | expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true); 1541 | expect(result.errors.some(e => e.message.includes('executeOnce must be a boolean'))).toBe(true); 1542 | expect(result.errors.some(e => e.message.includes('disabled must be a boolean'))).toBe(true); 1543 | expect(result.errors.some(e => e.message.includes('notesInFlow must be a boolean'))).toBe(true); 1544 | expect(result.errors.some(e => e.message.includes('notes must be a string'))).toBe(true); 1545 | }); 1546 | 1547 | it('should warn about executeOnce', async () => { 1548 | const workflow = { 1549 | nodes: [ 1550 | { 1551 | id: '1', 1552 | name: 'Set', 1553 | type: 'n8n-nodes-base.set', 1554 | position: [100, 100], 1555 | parameters: {}, 1556 | executeOnce: true 1557 | } 1558 | ], 1559 | connections: {} 1560 | } as any; 1561 | 1562 | const result = await validator.validateWorkflow(workflow as any); 1563 | 1564 | expect(result.warnings.some(w => w.message.includes('executeOnce is enabled'))).toBe(true); 1565 | }); 1566 | 1567 | it('should warn error-prone nodes without error handling', async () => { 1568 | const errorProneNodes = [ 1569 | { type: 'n8n-nodes-base.httpRequest', message: 'HTTP Request', version: 4 }, 1570 | { type: 'n8n-nodes-base.webhook', message: 'Webhook', version: 2 }, 1571 | { type: 'n8n-nodes-base.postgres', message: 'Database operation', version: 2 }, 1572 | { type: 'n8n-nodes-base.slack', message: 'slack node', version: 2 } 1573 | ]; 1574 | 1575 | for (const nodeInfo of errorProneNodes) { 1576 | const workflow = { 1577 | nodes: [ 1578 | { 1579 | id: '1', 1580 | name: 'Node', 1581 | type: nodeInfo.type, 1582 | position: [100, 100], 1583 | parameters: {}, 1584 | typeVersion: nodeInfo.version 1585 | } 1586 | ], 1587 | connections: {} 1588 | } as any; 1589 | 1590 | const result = await validator.validateWorkflow(workflow as any); 1591 | 1592 | expect(result.warnings.some(w => w.message.includes(nodeInfo.message) && w.message.includes('without error handling'))).toBe(true); 1593 | } 1594 | }); 1595 | 1596 | it('should warn about conflicting error handling', async () => { 1597 | const workflow = { 1598 | nodes: [ 1599 | { 1600 | id: '1', 1601 | name: 'HTTP', 1602 | type: 'n8n-nodes-base.httpRequest', 1603 | position: [100, 100], 1604 | parameters: {}, 1605 | continueOnFail: true, 1606 | retryOnFail: true 1607 | } 1608 | ], 1609 | connections: {} 1610 | } as any; 1611 | 1612 | const result = await validator.validateWorkflow(workflow as any); 1613 | 1614 | expect(result.warnings.some(w => w.message.includes('Both continueOnFail and retryOnFail are enabled'))).toBe(true); 1615 | }); 1616 | 1617 | it('should suggest alwaysOutputData for debugging', async () => { 1618 | const workflow = { 1619 | nodes: [ 1620 | { 1621 | id: '1', 1622 | name: 'HTTP', 1623 | type: 'n8n-nodes-base.httpRequest', 1624 | position: [100, 100], 1625 | parameters: {}, 1626 | retryOnFail: true 1627 | } 1628 | ], 1629 | connections: {} 1630 | } as any; 1631 | 1632 | const result = await validator.validateWorkflow(workflow as any); 1633 | 1634 | expect(result.suggestions.some(s => s.includes('Consider enabling alwaysOutputData'))).toBe(true); 1635 | }); 1636 | 1637 | it('should provide general error handling suggestions', async () => { 1638 | const builder = createWorkflow('No Error Handling'); 1639 | 1640 | // Add 6 nodes without error handling 1641 | for (let i = 0; i < 6; i++) { 1642 | builder.addCustomNode('n8n-nodes-base.httpRequest', 4, {}, { name: `HTTP${i}` }); 1643 | } 1644 | 1645 | const workflow = builder.build() as any; 1646 | 1647 | const result = await validator.validateWorkflow(workflow as any); 1648 | 1649 | expect(result.suggestions.some(s => s.includes('Most nodes lack error handling'))).toBe(true); 1650 | }); 1651 | 1652 | it('should suggest replacing deprecated error handling', async () => { 1653 | const workflow = { 1654 | nodes: [ 1655 | { 1656 | id: '1', 1657 | name: 'HTTP', 1658 | type: 'n8n-nodes-base.httpRequest', 1659 | position: [100, 100], 1660 | parameters: {}, 1661 | continueOnFail: true 1662 | } 1663 | ], 1664 | connections: {} 1665 | } as any; 1666 | 1667 | const result = await validator.validateWorkflow(workflow as any); 1668 | 1669 | expect(result.suggestions.some(s => s.includes('Replace "continueOnFail: true" with "onError:'))).toBe(true); 1670 | }); 1671 | }); 1672 | 1673 | describe('generateSuggestions', () => { 1674 | it('should suggest adding trigger for workflows without triggers', async () => { 1675 | const workflow = { 1676 | nodes: [ 1677 | { 1678 | id: '1', 1679 | name: 'Set', 1680 | type: 'n8n-nodes-base.set', 1681 | position: [100, 100], 1682 | parameters: {} 1683 | } 1684 | ], 1685 | connections: {} 1686 | } as any; 1687 | 1688 | const result = await validator.validateWorkflow(workflow as any); 1689 | 1690 | expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true); 1691 | }); 1692 | 1693 | it('should provide connection examples for connection errors', async () => { 1694 | const workflow = { 1695 | nodes: [ 1696 | { 1697 | id: '1', 1698 | name: 'Webhook', 1699 | type: 'n8n-nodes-base.webhook', 1700 | position: [100, 100], 1701 | parameters: {} 1702 | }, 1703 | { 1704 | id: '2', 1705 | name: 'Set', 1706 | type: 'n8n-nodes-base.set', 1707 | position: [300, 100], 1708 | parameters: {} 1709 | } 1710 | ], 1711 | connections: {} // Missing connections 1712 | } as any; 1713 | 1714 | const result = await validator.validateWorkflow(workflow as any); 1715 | 1716 | expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true); 1717 | expect(result.suggestions.some(s => s.includes('Use node NAMES (not IDs) in connections'))).toBe(true); 1718 | }); 1719 | 1720 | it('should suggest error handling when missing', async () => { 1721 | const workflow = { 1722 | nodes: [ 1723 | { 1724 | id: '1', 1725 | name: 'Webhook', 1726 | type: 'n8n-nodes-base.webhook', 1727 | position: [100, 100], 1728 | parameters: {} 1729 | }, 1730 | { 1731 | id: '2', 1732 | name: 'HTTP', 1733 | type: 'n8n-nodes-base.httpRequest', 1734 | position: [300, 100], 1735 | parameters: {} 1736 | } 1737 | ], 1738 | connections: { 1739 | 'Webhook': { 1740 | main: [[{ node: 'HTTP', type: 'main', index: 0 }]] 1741 | } 1742 | } 1743 | } as any; 1744 | 1745 | const result = await validator.validateWorkflow(workflow as any); 1746 | 1747 | expect(result.suggestions.some(s => s.includes('Add error handling'))).toBe(true); 1748 | }); 1749 | 1750 | it('should suggest breaking up large workflows', async () => { 1751 | const builder = createWorkflow('Large Workflow'); 1752 | 1753 | // Add 25 nodes 1754 | for (let i = 0; i < 25; i++) { 1755 | builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Node${i}` }); 1756 | } 1757 | 1758 | const workflow = builder.build() as any; 1759 | 1760 | const result = await validator.validateWorkflow(workflow as any); 1761 | 1762 | expect(result.suggestions.some(s => s.includes('Consider breaking this workflow into smaller sub-workflows'))).toBe(true); 1763 | }); 1764 | 1765 | it('should suggest Code node for complex expressions', async () => { 1766 | const workflow = { 1767 | nodes: [ 1768 | { 1769 | id: '1', 1770 | name: 'Complex', 1771 | type: 'n8n-nodes-base.set', 1772 | position: [100, 100], 1773 | parameters: { 1774 | field1: '={{ $json.a }}', 1775 | field2: '={{ $json.b }}', 1776 | field3: '={{ $json.c }}', 1777 | field4: '={{ $json.d }}', 1778 | field5: '={{ $json.e }}', 1779 | field6: '={{ $json.f }}' 1780 | } 1781 | } 1782 | ], 1783 | connections: {} 1784 | } as any; 1785 | 1786 | const result = await validator.validateWorkflow(workflow as any); 1787 | 1788 | expect(result.suggestions.some(s => s.includes('Consider using a Code node for complex data transformations'))).toBe(true); 1789 | }); 1790 | 1791 | it('should suggest minimal workflow structure', async () => { 1792 | const workflow = { 1793 | nodes: [ 1794 | { 1795 | id: '1', 1796 | name: 'Set', 1797 | type: 'n8n-nodes-base.set', 1798 | position: [100, 100], 1799 | parameters: {} 1800 | } 1801 | ], 1802 | connections: {} 1803 | } as any; 1804 | 1805 | const result = await validator.validateWorkflow(workflow as any); 1806 | 1807 | expect(result.suggestions.some(s => s.includes('A minimal workflow needs'))).toBe(true); 1808 | }); 1809 | }); 1810 | 1811 | describe('findSimilarNodeTypes', () => { 1812 | it.skip('should find similar node types for common mistakes', async () => { 1813 | // Test that webhook without prefix gets suggestions 1814 | const webhookWorkflow = { 1815 | nodes: [ 1816 | { 1817 | id: '1', 1818 | name: 'Node', 1819 | type: 'webhook', 1820 | position: [100, 100], 1821 | parameters: {} 1822 | } 1823 | ], 1824 | connections: {} 1825 | } as any; 1826 | 1827 | const webhookResult = await validator.validateWorkflow(webhookWorkflow); 1828 | 1829 | // Check that we get an unknown node error with suggestions 1830 | const unknownNodeError = webhookResult.errors.find(e => 1831 | e.message && e.message.includes('Unknown node type') 1832 | ); 1833 | expect(unknownNodeError).toBeDefined(); 1834 | 1835 | // For webhook, it should definitely suggest nodes-base.webhook 1836 | expect(unknownNodeError?.message).toContain('nodes-base.webhook'); 1837 | 1838 | // Test that slack without prefix gets suggestions 1839 | const slackWorkflow = { 1840 | nodes: [ 1841 | { 1842 | id: '1', 1843 | name: 'Node', 1844 | type: 'slack', 1845 | position: [100, 100], 1846 | parameters: {} 1847 | } 1848 | ], 1849 | connections: {} 1850 | } as any; 1851 | 1852 | const slackResult = await validator.validateWorkflow(slackWorkflow); 1853 | const slackError = slackResult.errors.find(e => 1854 | e.message && e.message.includes('Unknown node type') 1855 | ); 1856 | expect(slackError).toBeDefined(); 1857 | expect(slackError?.message).toContain('nodes-base.slack'); 1858 | }); 1859 | }); 1860 | 1861 | describe('Integration Tests', () => { 1862 | it('should validate a complex workflow with multiple issues', async () => { 1863 | const workflow = { 1864 | nodes: [ 1865 | // Valid trigger 1866 | { 1867 | id: '1', 1868 | name: 'Webhook', 1869 | type: 'n8n-nodes-base.webhook', 1870 | position: [100, 100], 1871 | parameters: {}, 1872 | typeVersion: 2 1873 | }, 1874 | // Node with valid alternative prefix (no longer an error) 1875 | { 1876 | id: '2', 1877 | name: 'HTTP1', 1878 | type: 'nodes-base.httpRequest', // Valid prefix (normalized internally) 1879 | position: [300, 100], 1880 | parameters: {} 1881 | }, 1882 | // Node with missing typeVersion 1883 | { 1884 | id: '3', 1885 | name: 'Slack', 1886 | type: 'n8n-nodes-base.slack', 1887 | position: [500, 100], 1888 | parameters: {} 1889 | }, 1890 | // Disabled node 1891 | { 1892 | id: '4', 1893 | name: 'Disabled', 1894 | type: 'n8n-nodes-base.set', 1895 | position: [700, 100], 1896 | parameters: {}, 1897 | disabled: true 1898 | }, 1899 | // Node with error handling in wrong place 1900 | { 1901 | id: '5', 1902 | name: 'HTTP2', 1903 | type: 'n8n-nodes-base.httpRequest', 1904 | position: [900, 100], 1905 | parameters: { 1906 | onError: 'continueRegularOutput' 1907 | }, 1908 | typeVersion: 4 1909 | }, 1910 | // Orphaned node 1911 | { 1912 | id: '6', 1913 | name: 'Orphaned', 1914 | type: 'n8n-nodes-base.code', 1915 | position: [1100, 100], 1916 | parameters: {}, 1917 | typeVersion: 2 1918 | }, 1919 | // AI Agent without tools 1920 | { 1921 | id: '7', 1922 | name: 'Agent', 1923 | type: '@n8n/n8n-nodes-langchain.agent', 1924 | position: [100, 300], 1925 | parameters: {}, 1926 | typeVersion: 1 1927 | } 1928 | ], 1929 | connections: { 1930 | 'Webhook': { 1931 | main: [[{ node: 'HTTP1', type: 'main', index: 0 }]] 1932 | }, 1933 | 'HTTP1': { 1934 | main: [[{ node: 'Slack', type: 'main', index: 0 }]] 1935 | }, 1936 | 'Slack': { 1937 | main: [[{ node: 'Disabled', type: 'main', index: 0 }]] 1938 | }, 1939 | // Using ID instead of name 1940 | '5': { 1941 | main: [[{ node: 'Agent', type: 'main', index: 0 }]] 1942 | } 1943 | } 1944 | } as any; 1945 | 1946 | const result = await validator.validateWorkflow(workflow as any); 1947 | 1948 | // Should have multiple errors (but not for the nodes-base prefix) 1949 | expect(result.valid).toBe(false); 1950 | expect(result.errors.length).toBeGreaterThan(2); // Reduced by 1 since nodes-base prefix is now valid 1951 | 1952 | // Specific errors (removed the invalid node type error as it's no longer invalid) 1953 | expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true); 1954 | expect(result.errors.some(e => e.message.includes('Node-level properties onError are in the wrong location'))).toBe(true); 1955 | expect(result.errors.some(e => e.message.includes('Connection uses node ID \'5\' instead of node name'))).toBe(true); 1956 | 1957 | // Warnings 1958 | expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true); 1959 | expect(result.warnings.some(w => w.message.includes('Node is not connected') && w.nodeName === 'Orphaned')).toBe(true); 1960 | expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true); 1961 | 1962 | // Statistics 1963 | expect(result.statistics.totalNodes).toBe(7); 1964 | expect(result.statistics.enabledNodes).toBe(6); 1965 | expect(result.statistics.triggerNodes).toBe(1); 1966 | expect(result.statistics.invalidConnections).toBeGreaterThan(0); 1967 | 1968 | // Suggestions 1969 | expect(result.suggestions.length).toBeGreaterThan(0); 1970 | }); 1971 | 1972 | it('should validate a perfect workflow', async () => { 1973 | const workflow = { 1974 | nodes: [ 1975 | { 1976 | id: '1', 1977 | name: 'Manual Trigger', 1978 | type: 'n8n-nodes-base.manualTrigger', 1979 | position: [250, 300], 1980 | parameters: {}, 1981 | typeVersion: 1 1982 | }, 1983 | { 1984 | id: '2', 1985 | name: 'HTTP Request', 1986 | type: 'n8n-nodes-base.httpRequest', 1987 | position: [450, 300], 1988 | parameters: { 1989 | url: 'https://api.example.com/data', 1990 | method: 'GET' 1991 | }, 1992 | typeVersion: 4, 1993 | onError: 'continueErrorOutput', 1994 | retryOnFail: true, 1995 | maxTries: 3, 1996 | waitBetweenTries: 1000 1997 | }, 1998 | { 1999 | id: '3', 2000 | name: 'Process Data', 2001 | type: 'n8n-nodes-base.code', 2002 | position: [650, 300], 2003 | parameters: { 2004 | jsCode: 'return items;' 2005 | }, 2006 | typeVersion: 2 2007 | }, 2008 | { 2009 | id: '4', 2010 | name: 'Error Handler', 2011 | type: 'n8n-nodes-base.set', 2012 | position: [650, 500], 2013 | parameters: { 2014 | values: { 2015 | string: [ 2016 | { 2017 | name: 'error', 2018 | value: 'An error occurred' 2019 | } 2020 | ] 2021 | } 2022 | }, 2023 | typeVersion: 3 2024 | } 2025 | ], 2026 | connections: { 2027 | 'Manual Trigger': { 2028 | main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] 2029 | }, 2030 | 'HTTP Request': { 2031 | main: [ 2032 | [{ node: 'Process Data', type: 'main', index: 0 }], 2033 | [{ node: 'Error Handler', type: 'main', index: 0 }] 2034 | ] 2035 | } 2036 | } 2037 | } as any; 2038 | 2039 | const result = await validator.validateWorkflow(workflow as any); 2040 | 2041 | expect(result.valid).toBe(true); 2042 | expect(result.errors).toHaveLength(0); 2043 | expect(result.warnings).toHaveLength(0); 2044 | expect(result.statistics.validConnections).toBe(3); 2045 | expect(result.statistics.invalidConnections).toBe(0); 2046 | }); 2047 | }); 2048 | }); ```