This is page 35 of 45. Use http://codebase.md/czlonkowski/n8n-mcp?lines=false&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 │ │ │ ├── sqljs-memory-leak.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 import { N8nApiClient } from '../services/n8n-api-client'; import { getN8nApiConfig, getN8nApiConfigFromContext } from '../config/n8n-api'; import { Workflow, WorkflowNode, WorkflowConnection, ExecutionStatus, WebhookRequest, McpToolResponse, ExecutionFilterOptions, ExecutionMode } from '../types/n8n-api'; import { validateWorkflowStructure, hasWebhookTrigger, getWebhookUrl } from '../services/n8n-validation'; import { N8nApiError, N8nNotFoundError, getUserFriendlyErrorMessage, formatExecutionError, formatNoExecutionError } from '../utils/n8n-errors'; import { logger } from '../utils/logger'; import { z } from 'zod'; import { WorkflowValidator } from '../services/workflow-validator'; import { EnhancedConfigValidator } from '../services/enhanced-config-validator'; import { NodeRepository } from '../database/node-repository'; import { InstanceContext, validateInstanceContext } from '../types/instance-context'; import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; import { WorkflowAutoFixer, AutoFixConfig } from '../services/workflow-auto-fixer'; import { ExpressionFormatValidator, ExpressionFormatIssue } from '../services/expression-format-validator'; import { handleUpdatePartialWorkflow } from './handlers-workflow-diff'; import { telemetry } from '../telemetry'; import { createCacheKey, createInstanceCache, CacheMutex, cacheMetrics, withRetry, getCacheStatistics } from '../utils/cache-utils'; import { processExecution } from '../services/execution-processor'; import { checkNpmVersion, formatVersionMessage } from '../utils/npm-version-checker'; // ======================================================================== // TypeScript Interfaces for Type Safety // ======================================================================== /** * Health Check Response Data Structure */ interface HealthCheckResponseData { status: string; instanceId?: string; n8nVersion?: string; features?: Record<string, unknown>; apiUrl?: string; mcpVersion: string; supportedN8nVersion?: string; versionCheck: { current: string; latest: string | null; upToDate: boolean; message: string; updateCommand?: string; }; performance: { responseTimeMs: number; cacheHitRate: string; cachedInstances: number; }; nextSteps?: string[]; updateWarning?: string; } /** * Cloud Platform Guide Structure */ interface CloudPlatformGuide { name: string; troubleshooting: string[]; } /** * Workflow Validation Response Data */ interface WorkflowValidationResponse { valid: boolean; workflowId?: string; workflowName?: string; summary: { totalNodes: number; enabledNodes: number; triggerNodes: number; validConnections: number; invalidConnections: number; expressionsValidated: number; errorCount: number; warningCount: number; }; errors?: Array<{ node: string; nodeName?: string; message: string; details?: Record<string, unknown>; }>; warnings?: Array<{ node: string; nodeName?: string; message: string; details?: Record<string, unknown>; }>; suggestions?: unknown[]; } /** * Diagnostic Response Data Structure */ interface DiagnosticResponseData { timestamp: string; environment: { N8N_API_URL: string | null; N8N_API_KEY: string | null; NODE_ENV: string; MCP_MODE: string; isDocker: boolean; cloudPlatform: string | null; nodeVersion: string; platform: string; }; apiConfiguration: { configured: boolean; status: { configured: boolean; connected: boolean; error: string | null; version: string | null; }; config: { baseUrl: string; timeout: number; maxRetries: number; } | null; }; versionInfo: { current: string; latest: string | null; upToDate: boolean; message: string; updateCommand?: string; }; toolsAvailability: { documentationTools: { count: number; enabled: boolean; description: string; }; managementTools: { count: number; enabled: boolean; description: string; }; totalAvailable: number; }; performance: { diagnosticResponseTimeMs: number; cacheHitRate: string; cachedInstances: number; }; modeSpecificDebug: Record<string, unknown>; dockerDebug?: Record<string, unknown>; cloudPlatformDebug?: CloudPlatformGuide; nextSteps?: Record<string, unknown>; troubleshooting?: Record<string, unknown>; setupGuide?: Record<string, unknown>; updateWarning?: Record<string, unknown>; debug?: Record<string, unknown>; [key: string]: unknown; // Allow dynamic property access for optional fields } // ======================================================================== // Singleton n8n API client instance (backward compatibility) let defaultApiClient: N8nApiClient | null = null; let lastDefaultConfigUrl: string | null = null; // Mutex for cache operations to prevent race conditions const cacheMutex = new CacheMutex(); // Instance-specific API clients cache with LRU eviction and TTL const instanceClients = createInstanceCache<N8nApiClient>((client, key) => { // Clean up when evicting from cache logger.debug('Evicting API client from cache', { cacheKey: key.substring(0, 8) + '...' // Only log partial key for security }); }); /** * Get or create API client with flexible instance support * Supports both singleton mode (using environment variables) and instance-specific mode. * Uses LRU cache with mutex protection for thread-safe operations. * * @param context - Optional instance context for instance-specific configuration * @returns API client configured for the instance or environment, or null if not configured * * @example * // Using environment variables (singleton mode) * const client = getN8nApiClient(); * * @example * // Using instance context * const client = getN8nApiClient({ * n8nApiUrl: 'https://customer.n8n.cloud', * n8nApiKey: 'api-key-123', * instanceId: 'customer-1' * }); */ /** * Get cache statistics for monitoring * @returns Formatted cache statistics string */ export function getInstanceCacheStatistics(): string { return getCacheStatistics(); } /** * Get raw cache metrics for detailed monitoring * @returns Raw cache metrics object */ export function getInstanceCacheMetrics() { return cacheMetrics.getMetrics(); } /** * Clear the instance cache for testing or maintenance */ export function clearInstanceCache(): void { instanceClients.clear(); cacheMetrics.recordClear(); cacheMetrics.updateSize(0, instanceClients.max); } export function getN8nApiClient(context?: InstanceContext): N8nApiClient | null { // If context provided with n8n config, use instance-specific client if (context?.n8nApiUrl && context?.n8nApiKey) { // Validate context before using const validation = validateInstanceContext(context); if (!validation.valid) { logger.warn('Invalid instance context provided', { instanceId: context.instanceId, errors: validation.errors }); return null; } // Create secure hash of credentials for cache key using memoization const cacheKey = createCacheKey( `${context.n8nApiUrl}:${context.n8nApiKey}:${context.instanceId || ''}` ); // Check cache first if (instanceClients.has(cacheKey)) { cacheMetrics.recordHit(); return instanceClients.get(cacheKey) || null; } cacheMetrics.recordMiss(); // Check if already being created (simple lock check) if (cacheMutex.isLocked(cacheKey)) { // Wait briefly and check again const waitTime = 100; // 100ms const start = Date.now(); while (cacheMutex.isLocked(cacheKey) && (Date.now() - start) < 1000) { // Busy wait for up to 1 second } // Check if it was created while waiting if (instanceClients.has(cacheKey)) { cacheMetrics.recordHit(); return instanceClients.get(cacheKey) || null; } } const config = getN8nApiConfigFromContext(context); if (config) { // Sanitized logging - never log API keys logger.info('Creating instance-specific n8n API client', { url: config.baseUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1'), // Only log domain instanceId: context.instanceId, cacheKey: cacheKey.substring(0, 8) + '...' // Only log partial hash }); const client = new N8nApiClient(config); instanceClients.set(cacheKey, client); cacheMetrics.recordSet(); cacheMetrics.updateSize(instanceClients.size, instanceClients.max); return client; } return null; } // Fall back to default singleton from environment logger.info('Falling back to environment configuration for n8n API client'); const config = getN8nApiConfig(); if (!config) { if (defaultApiClient) { logger.info('n8n API configuration removed, clearing default client'); defaultApiClient = null; lastDefaultConfigUrl = null; } return null; } // Check if config has changed if (!defaultApiClient || lastDefaultConfigUrl !== config.baseUrl) { logger.info('n8n API client initialized from environment', { url: config.baseUrl }); defaultApiClient = new N8nApiClient(config); lastDefaultConfigUrl = config.baseUrl; } return defaultApiClient; } /** * Helper to ensure API is configured * @param context - Optional instance context * @returns Configured API client * @throws Error if API is not configured */ function ensureApiConfigured(context?: InstanceContext): N8nApiClient { const client = getN8nApiClient(context); if (!client) { if (context?.instanceId) { throw new Error(`n8n API not configured for instance ${context.instanceId}. Please provide n8nApiUrl and n8nApiKey in the instance context.`); } throw new Error('n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.'); } return client; } // Zod schemas for input validation const createWorkflowSchema = z.object({ name: z.string(), nodes: z.array(z.any()), connections: z.record(z.any()), settings: z.object({ executionOrder: z.enum(['v0', 'v1']).optional(), timezone: z.string().optional(), saveDataErrorExecution: z.enum(['all', 'none']).optional(), saveDataSuccessExecution: z.enum(['all', 'none']).optional(), saveManualExecutions: z.boolean().optional(), saveExecutionProgress: z.boolean().optional(), executionTimeout: z.number().optional(), errorWorkflow: z.string().optional(), }).optional(), }); const updateWorkflowSchema = z.object({ id: z.string(), name: z.string().optional(), nodes: z.array(z.any()).optional(), connections: z.record(z.any()).optional(), settings: z.any().optional(), }); const listWorkflowsSchema = z.object({ limit: z.number().min(1).max(100).optional(), cursor: z.string().optional(), active: z.boolean().optional(), tags: z.array(z.string()).optional(), projectId: z.string().optional(), excludePinnedData: z.boolean().optional(), }); const validateWorkflowSchema = z.object({ id: z.string(), options: z.object({ validateNodes: z.boolean().optional(), validateConnections: z.boolean().optional(), validateExpressions: z.boolean().optional(), profile: z.enum(['minimal', 'runtime', 'ai-friendly', 'strict']).optional(), }).optional(), }); const autofixWorkflowSchema = z.object({ id: z.string(), applyFixes: z.boolean().optional().default(false), fixTypes: z.array(z.enum([ 'expression-format', 'typeversion-correction', 'error-output-config', 'node-type-correction', 'webhook-missing-path' ])).optional(), confidenceThreshold: z.enum(['high', 'medium', 'low']).optional().default('medium'), maxFixes: z.number().optional().default(50) }); const triggerWebhookSchema = z.object({ webhookUrl: z.string().url(), httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(), data: z.record(z.unknown()).optional(), headers: z.record(z.string()).optional(), waitForResponse: z.boolean().optional(), }); const listExecutionsSchema = z.object({ limit: z.number().min(1).max(100).optional(), cursor: z.string().optional(), workflowId: z.string().optional(), projectId: z.string().optional(), status: z.enum(['success', 'error', 'waiting']).optional(), includeData: z.boolean().optional(), }); // Workflow Management Handlers export async function handleCreateWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const input = createWorkflowSchema.parse(args); // Proactively detect SHORT form node types (common mistake) const shortFormErrors: string[] = []; input.nodes?.forEach((node: any, index: number) => { if (node.type?.startsWith('nodes-base.') || node.type?.startsWith('nodes-langchain.')) { const fullForm = node.type.startsWith('nodes-base.') ? node.type.replace('nodes-base.', 'n8n-nodes-base.') : node.type.replace('nodes-langchain.', '@n8n/n8n-nodes-langchain.'); shortFormErrors.push( `Node ${index} ("${node.name}") uses SHORT form "${node.type}". ` + `The n8n API requires FULL form. Change to "${fullForm}"` ); } }); if (shortFormErrors.length > 0) { telemetry.trackWorkflowCreation(input, false); return { success: false, error: 'Node type format error: n8n API requires FULL form node types', details: { errors: shortFormErrors, hint: 'Use n8n-nodes-base.* instead of nodes-base.* for standard nodes' } }; } // Validate workflow structure (n8n API expects FULL form: n8n-nodes-base.*) const errors = validateWorkflowStructure(input); if (errors.length > 0) { // Track validation failure telemetry.trackWorkflowCreation(input, false); return { success: false, error: 'Workflow validation failed', details: { errors } }; } // Create workflow (n8n API expects node types in FULL form) const workflow = await client.createWorkflow(input); // Track successful workflow creation telemetry.trackWorkflowCreation(workflow, true); return { success: true, data: workflow, message: `Workflow "${workflow.name}" created successfully with ID: ${workflow.id}` }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code, details: error.details as Record<string, unknown> | undefined }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleGetWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const { id } = z.object({ id: z.string() }).parse(args); const workflow = await client.getWorkflow(id); return { success: true, data: workflow }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleGetWorkflowDetails(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const { id } = z.object({ id: z.string() }).parse(args); const workflow = await client.getWorkflow(id); // Get recent executions for this workflow const executions = await client.listExecutions({ workflowId: id, limit: 10 }); // Calculate execution statistics const stats = { totalExecutions: executions.data.length, successCount: executions.data.filter(e => e.status === ExecutionStatus.SUCCESS).length, errorCount: executions.data.filter(e => e.status === ExecutionStatus.ERROR).length, lastExecutionTime: executions.data[0]?.startedAt || null }; return { success: true, data: { workflow, executionStats: stats, hasWebhookTrigger: hasWebhookTrigger(workflow), webhookPath: getWebhookUrl(workflow) } }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleGetWorkflowStructure(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const { id } = z.object({ id: z.string() }).parse(args); const workflow = await client.getWorkflow(id); // Simplify nodes to just essential structure const simplifiedNodes = workflow.nodes.map(node => ({ id: node.id, name: node.name, type: node.type, position: node.position, disabled: node.disabled || false })); return { success: true, data: { id: workflow.id, name: workflow.name, active: workflow.active, isArchived: workflow.isArchived, nodes: simplifiedNodes, connections: workflow.connections, nodeCount: workflow.nodes.length, connectionCount: Object.keys(workflow.connections).length } }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleGetWorkflowMinimal(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const { id } = z.object({ id: z.string() }).parse(args); const workflow = await client.getWorkflow(id); return { success: true, data: { id: workflow.id, name: workflow.name, active: workflow.active, isArchived: workflow.isArchived, tags: workflow.tags || [], createdAt: workflow.createdAt, updatedAt: workflow.updatedAt } }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleUpdateWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const input = updateWorkflowSchema.parse(args); const { id, ...updateData } = input; // If nodes/connections are being updated, validate the structure if (updateData.nodes || updateData.connections) { // Always fetch current workflow for validation (need all fields like name) const current = await client.getWorkflow(id); const fullWorkflow = { ...current, ...updateData }; // Validate workflow structure (n8n API expects FULL form: n8n-nodes-base.*) const errors = validateWorkflowStructure(fullWorkflow); if (errors.length > 0) { return { success: false, error: 'Workflow validation failed', details: { errors } }; } } // Update workflow const workflow = await client.updateWorkflow(id, updateData); return { success: true, data: workflow, message: `Workflow "${workflow.name}" updated successfully` }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code, details: error.details as Record<string, unknown> | undefined }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleDeleteWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const { id } = z.object({ id: z.string() }).parse(args); const deleted = await client.deleteWorkflow(id); return { success: true, data: deleted, message: `Workflow ${id} deleted successfully` }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleListWorkflows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const input = listWorkflowsSchema.parse(args || {}); // Convert tags array to comma-separated string (n8n API format) const tagsParam = input.tags && input.tags.length > 0 ? input.tags.join(',') : undefined; const response = await client.listWorkflows({ limit: input.limit || 100, cursor: input.cursor, active: input.active, tags: tagsParam as any, // API expects string, not array projectId: input.projectId, excludePinnedData: input.excludePinnedData ?? true }); // Strip down workflows to only essential metadata const minimalWorkflows = response.data.map(workflow => ({ id: workflow.id, name: workflow.name, active: workflow.active, isArchived: workflow.isArchived, createdAt: workflow.createdAt, updatedAt: workflow.updatedAt, tags: workflow.tags || [], nodeCount: workflow.nodes?.length || 0 })); return { success: true, data: { workflows: minimalWorkflows, returned: minimalWorkflows.length, nextCursor: response.nextCursor, hasMore: !!response.nextCursor, ...(response.nextCursor ? { _note: "More workflows available. Use cursor to get next page." } : {}) } }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleValidateWorkflow( args: unknown, repository: NodeRepository, context?: InstanceContext ): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const input = validateWorkflowSchema.parse(args); // First, fetch the workflow from n8n const workflowResponse = await handleGetWorkflow({ id: input.id }); if (!workflowResponse.success) { return workflowResponse; // Return the error from fetching } const workflow = workflowResponse.data as Workflow; // Create validator instance using the provided repository const validator = new WorkflowValidator(repository, EnhancedConfigValidator); // Run validation const validationResult = await validator.validateWorkflow(workflow, input.options); // Format the response (same format as the regular validate_workflow tool) const response: WorkflowValidationResponse = { valid: validationResult.valid, workflowId: workflow.id, workflowName: workflow.name, summary: { totalNodes: validationResult.statistics.totalNodes, enabledNodes: validationResult.statistics.enabledNodes, triggerNodes: validationResult.statistics.triggerNodes, validConnections: validationResult.statistics.validConnections, invalidConnections: validationResult.statistics.invalidConnections, expressionsValidated: validationResult.statistics.expressionsValidated, errorCount: validationResult.errors.length, warningCount: validationResult.warnings.length } }; if (validationResult.errors.length > 0) { response.errors = validationResult.errors.map(e => ({ node: e.nodeName || 'workflow', nodeName: e.nodeName, // Also set nodeName for compatibility message: e.message, details: e.details })); } if (validationResult.warnings.length > 0) { response.warnings = validationResult.warnings.map(w => ({ node: w.nodeName || 'workflow', nodeName: w.nodeName, // Also set nodeName for compatibility message: w.message, details: w.details })); } if (validationResult.suggestions.length > 0) { response.suggestions = validationResult.suggestions; } // Track successfully validated workflows in telemetry if (validationResult.valid) { telemetry.trackWorkflowCreation(workflow, true); } return { success: true, data: response }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleAutofixWorkflow( args: unknown, repository: NodeRepository, context?: InstanceContext ): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const input = autofixWorkflowSchema.parse(args); // First, fetch the workflow from n8n const workflowResponse = await handleGetWorkflow({ id: input.id }, context); if (!workflowResponse.success) { return workflowResponse; // Return the error from fetching } const workflow = workflowResponse.data as Workflow; // Create validator instance using the provided repository const validator = new WorkflowValidator(repository, EnhancedConfigValidator); // Run validation to identify issues const validationResult = await validator.validateWorkflow(workflow, { validateNodes: true, validateConnections: true, validateExpressions: true, profile: 'ai-friendly' }); // Check for expression format issues const allFormatIssues: ExpressionFormatIssue[] = []; for (const node of workflow.nodes) { const formatContext = { nodeType: node.type, nodeName: node.name, nodeId: node.id }; const nodeFormatIssues = ExpressionFormatValidator.validateNodeParameters( node.parameters, formatContext ); // Add node information to each format issue const enrichedIssues = nodeFormatIssues.map(issue => ({ ...issue, nodeName: node.name, nodeId: node.id })); allFormatIssues.push(...enrichedIssues); } // Generate fixes using WorkflowAutoFixer const autoFixer = new WorkflowAutoFixer(repository); const fixResult = autoFixer.generateFixes( workflow, validationResult, allFormatIssues, { applyFixes: input.applyFixes, fixTypes: input.fixTypes, confidenceThreshold: input.confidenceThreshold, maxFixes: input.maxFixes } ); // If no fixes available if (fixResult.fixes.length === 0) { return { success: true, data: { workflowId: workflow.id, workflowName: workflow.name, message: 'No automatic fixes available for this workflow', validationSummary: { errors: validationResult.errors.length, warnings: validationResult.warnings.length } } }; } // If preview mode (applyFixes = false) if (!input.applyFixes) { return { success: true, data: { workflowId: workflow.id, workflowName: workflow.name, preview: true, fixesAvailable: fixResult.fixes.length, fixes: fixResult.fixes, summary: fixResult.summary, stats: fixResult.stats, message: `${fixResult.fixes.length} fixes available. Set applyFixes=true to apply them.` } }; } // Apply fixes using the diff engine if (fixResult.operations.length > 0) { const updateResult = await handleUpdatePartialWorkflow( { id: workflow.id, operations: fixResult.operations }, context ); if (!updateResult.success) { return { success: false, error: 'Failed to apply fixes', details: { fixes: fixResult.fixes, updateError: updateResult.error } }; } return { success: true, data: { workflowId: workflow.id, workflowName: workflow.name, fixesApplied: fixResult.fixes.length, fixes: fixResult.fixes, summary: fixResult.summary, stats: fixResult.stats, message: `Successfully applied ${fixResult.fixes.length} fixes to workflow "${workflow.name}"` } }; } return { success: true, data: { workflowId: workflow.id, workflowName: workflow.name, message: 'No fixes needed' } }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } // Execution Management Handlers export async function handleTriggerWebhookWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const input = triggerWebhookSchema.parse(args); const webhookRequest: WebhookRequest = { webhookUrl: input.webhookUrl, httpMethod: input.httpMethod || 'POST', data: input.data, headers: input.headers, waitForResponse: input.waitForResponse ?? true }; const response = await client.triggerWebhook(webhookRequest); return { success: true, data: response, message: 'Webhook triggered successfully' }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { // Try to extract execution context from error response const errorData = error.details as any; const executionId = errorData?.executionId || errorData?.id || errorData?.execution?.id; const workflowId = errorData?.workflowId || errorData?.workflow?.id; // If we have execution ID, provide specific guidance with n8n_get_execution if (executionId) { return { success: false, error: formatExecutionError(executionId, workflowId), code: error.code, executionId, workflowId: workflowId || undefined }; } // No execution ID available - workflow likely didn't start // Provide guidance to check recent executions if (error.code === 'SERVER_ERROR' || error.statusCode && error.statusCode >= 500) { return { success: false, error: formatNoExecutionError(), code: error.code }; } // For other errors (auth, validation, etc), use standard message return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code, details: error.details as Record<string, unknown> | undefined }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleGetExecution(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); // Parse and validate input with new parameters const schema = z.object({ id: z.string(), // New filtering parameters mode: z.enum(['preview', 'summary', 'filtered', 'full']).optional(), nodeNames: z.array(z.string()).optional(), itemsLimit: z.number().optional(), includeInputData: z.boolean().optional(), // Legacy parameter (backward compatibility) includeData: z.boolean().optional() }); const params = schema.parse(args); const { id, mode, nodeNames, itemsLimit, includeInputData, includeData } = params; /** * Map legacy includeData parameter to mode for backward compatibility * * Legacy behavior: * - includeData: undefined -> minimal execution summary (no data) * - includeData: false -> minimal execution summary (no data) * - includeData: true -> full execution data * * New behavior mapping: * - includeData: undefined -> no mode (minimal) * - includeData: false -> no mode (minimal) * - includeData: true -> mode: 'summary' (2 items per node, not full) * * Note: Legacy true behavior returned ALL data, which could exceed token limits. * New behavior caps at 2 items for safety. Users can use mode: 'full' for old behavior. */ let effectiveMode = mode; if (!effectiveMode && includeData !== undefined) { effectiveMode = includeData ? 'summary' : undefined; } // Determine if we need to fetch full data from API // We fetch full data if any mode is specified (including preview) or legacy includeData is true // Preview mode needs the data to analyze structure and generate recommendations const fetchFullData = effectiveMode !== undefined || includeData === true; // Fetch execution from n8n API const execution = await client.getExecution(id, fetchFullData); // If no filtering options specified, return original execution (backward compatibility) if (!effectiveMode && !nodeNames && itemsLimit === undefined) { return { success: true, data: execution }; } // Apply filtering using ExecutionProcessor const filterOptions: ExecutionFilterOptions = { mode: effectiveMode, nodeNames, itemsLimit, includeInputData }; const processedExecution = processExecution(execution, filterOptions); return { success: true, data: processedExecution }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleListExecutions(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const input = listExecutionsSchema.parse(args || {}); const response = await client.listExecutions({ limit: input.limit || 100, cursor: input.cursor, workflowId: input.workflowId, projectId: input.projectId, status: input.status as ExecutionStatus | undefined, includeData: input.includeData || false }); return { success: true, data: { executions: response.data, returned: response.data.length, nextCursor: response.nextCursor, hasMore: !!response.nextCursor, ...(response.nextCursor ? { _note: "More executions available. Use cursor to get next page." } : {}) } }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleDeleteExecution(args: unknown, context?: InstanceContext): Promise<McpToolResponse> { try { const client = ensureApiConfigured(context); const { id } = z.object({ id: z.string() }).parse(args); await client.deleteExecution(id); return { success: true, message: `Execution ${id} deleted successfully` }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: 'Invalid input', details: { errors: error.errors } }; } if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } // System Tools Handlers export async function handleHealthCheck(context?: InstanceContext): Promise<McpToolResponse> { const startTime = Date.now(); try { const client = ensureApiConfigured(context); const health = await client.healthCheck(); // Get MCP version from package.json const packageJson = require('../../package.json'); const mcpVersion = packageJson.version; const supportedN8nVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, ''); // Check npm for latest version (async, non-blocking) const versionCheck = await checkNpmVersion(); // Get cache metrics for performance monitoring const cacheMetricsData = getInstanceCacheMetrics(); // Calculate response time const responseTime = Date.now() - startTime; // Build response data const responseData: HealthCheckResponseData = { status: health.status, instanceId: health.instanceId, n8nVersion: health.n8nVersion, features: health.features, apiUrl: getN8nApiConfig()?.baseUrl, mcpVersion, supportedN8nVersion, versionCheck: { current: versionCheck.currentVersion, latest: versionCheck.latestVersion, upToDate: !versionCheck.isOutdated, message: formatVersionMessage(versionCheck), ...(versionCheck.updateCommand ? { updateCommand: versionCheck.updateCommand } : {}) }, performance: { responseTimeMs: responseTime, cacheHitRate: (cacheMetricsData.hits + cacheMetricsData.misses) > 0 ? ((cacheMetricsData.hits / (cacheMetricsData.hits + cacheMetricsData.misses)) * 100).toFixed(2) + '%' : 'N/A', cachedInstances: cacheMetricsData.size } }; // Add next steps guidance based on telemetry insights responseData.nextSteps = [ '• Create workflow: n8n_create_workflow', '• List workflows: n8n_list_workflows', '• Search nodes: search_nodes', '• Browse templates: search_templates' ]; // Add update warning if outdated if (versionCheck.isOutdated && versionCheck.latestVersion) { responseData.updateWarning = `⚠️ n8n-mcp v${versionCheck.latestVersion} is available (you have v${versionCheck.currentVersion}). Update recommended.`; } // Track result in telemetry telemetry.trackEvent('health_check_completed', { success: true, responseTimeMs: responseTime, upToDate: !versionCheck.isOutdated, apiConnected: true }); return { success: true, data: responseData }; } catch (error) { const responseTime = Date.now() - startTime; // Track failure in telemetry telemetry.trackEvent('health_check_failed', { success: false, responseTimeMs: responseTime, errorType: error instanceof N8nApiError ? error.code : 'unknown' }); if (error instanceof N8nApiError) { return { success: false, error: getUserFriendlyErrorMessage(error), code: error.code, details: { apiUrl: getN8nApiConfig()?.baseUrl, hint: 'Check if n8n is running and API is enabled', troubleshooting: [ '1. Verify n8n instance is running', '2. Check N8N_API_URL is correct', '3. Verify N8N_API_KEY has proper permissions', '4. Run n8n_diagnostic for detailed analysis' ] } }; } return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } export async function handleListAvailableTools(context?: InstanceContext): Promise<McpToolResponse> { const tools = [ { category: 'Workflow Management', tools: [ { name: 'n8n_create_workflow', description: 'Create new workflows' }, { name: 'n8n_get_workflow', description: 'Get workflow by ID' }, { name: 'n8n_get_workflow_details', description: 'Get detailed workflow info with stats' }, { name: 'n8n_get_workflow_structure', description: 'Get simplified workflow structure' }, { name: 'n8n_get_workflow_minimal', description: 'Get minimal workflow info' }, { name: 'n8n_update_workflow', description: 'Update existing workflows' }, { name: 'n8n_delete_workflow', description: 'Delete workflows' }, { name: 'n8n_list_workflows', description: 'List workflows with filters' }, { name: 'n8n_validate_workflow', description: 'Validate workflow from n8n instance' }, { name: 'n8n_autofix_workflow', description: 'Automatically fix common workflow errors' } ] }, { category: 'Execution Management', tools: [ { name: 'n8n_trigger_webhook_workflow', description: 'Trigger workflows via webhook' }, { name: 'n8n_get_execution', description: 'Get execution details' }, { name: 'n8n_list_executions', description: 'List executions with filters' }, { name: 'n8n_delete_execution', description: 'Delete execution records' } ] }, { category: 'System', tools: [ { name: 'n8n_health_check', description: 'Check API connectivity' }, { name: 'n8n_list_available_tools', description: 'List all available tools' } ] } ]; const config = getN8nApiConfig(); const apiConfigured = config !== null; return { success: true, data: { tools, apiConfigured, configuration: config ? { apiUrl: config.baseUrl, timeout: config.timeout, maxRetries: config.maxRetries } : null, limitations: [ 'Cannot activate/deactivate workflows via API', 'Cannot execute workflows directly (must use webhooks)', 'Cannot stop running executions', 'Tags and credentials have limited API support' ] } }; } // Environment-aware debugging helpers /** * Detect cloud platform from environment variables * Returns platform name or null if not in cloud */ function detectCloudPlatform(): string | null { if (process.env.RAILWAY_ENVIRONMENT) return 'railway'; if (process.env.RENDER) return 'render'; if (process.env.FLY_APP_NAME) return 'fly'; if (process.env.HEROKU_APP_NAME) return 'heroku'; if (process.env.AWS_EXECUTION_ENV) return 'aws'; if (process.env.KUBERNETES_SERVICE_HOST) return 'kubernetes'; if (process.env.GOOGLE_CLOUD_PROJECT) return 'gcp'; if (process.env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure'; return null; } /** * Get mode-specific debugging suggestions */ function getModeSpecificDebug(mcpMode: string) { if (mcpMode === 'http') { const port = process.env.MCP_PORT || process.env.PORT || 3000; return { mode: 'HTTP Server', port, authTokenConfigured: !!(process.env.MCP_AUTH_TOKEN || process.env.AUTH_TOKEN), corsEnabled: true, serverUrl: `http://localhost:${port}`, healthCheckUrl: `http://localhost:${port}/health`, troubleshooting: [ `1. Test server health: curl http://localhost:${port}/health`, '2. Check browser console for CORS errors', '3. Verify MCP_AUTH_TOKEN or AUTH_TOKEN if authentication enabled', `4. Ensure port ${port} is not in use: lsof -i :${port} (macOS/Linux) or netstat -ano | findstr :${port} (Windows)`, '5. Check firewall settings for port access', '6. Review server logs for connection errors' ], commonIssues: [ 'CORS policy blocking browser requests', 'Port already in use by another application', 'Authentication token mismatch', 'Network firewall blocking connections' ] }; } else { // stdio mode const configLocation = process.platform === 'darwin' ? '~/Library/Application Support/Claude/claude_desktop_config.json' : process.platform === 'win32' ? '%APPDATA%\\Claude\\claude_desktop_config.json' : '~/.config/Claude/claude_desktop_config.json'; return { mode: 'Standard I/O (Claude Desktop)', configLocation, troubleshooting: [ '1. Verify Claude Desktop config file exists and is valid JSON', '2. Check MCP server entry: {"mcpServers": {"n8n": {"command": "npx", "args": ["-y", "n8n-mcp"]}}}', '3. Restart Claude Desktop after config changes', '4. Check Claude Desktop logs for startup errors', '5. Test npx can run: npx -y n8n-mcp --version', '6. Verify executable permissions if using local installation' ], commonIssues: [ 'Invalid JSON in claude_desktop_config.json', 'Incorrect command or args in MCP server config', 'Claude Desktop not restarted after config changes', 'npx unable to download or run package', 'Missing execute permissions on local binary' ] }; } } /** * Get Docker-specific debugging suggestions */ function getDockerDebug(isDocker: boolean) { if (!isDocker) return null; return { containerDetected: true, troubleshooting: [ '1. Verify volume mounts for data/nodes.db', '2. Check network connectivity to n8n instance', '3. Ensure ports are correctly mapped', '4. Review container logs: docker logs <container-name>', '5. Verify environment variables passed to container', '6. Check IS_DOCKER=true is set correctly' ], commonIssues: [ 'Volume mount not persisting database', 'Network isolation preventing n8n API access', 'Port mapping conflicts', 'Missing environment variables in container' ] }; } /** * Get cloud platform-specific suggestions */ function getCloudPlatformDebug(cloudPlatform: string | null) { if (!cloudPlatform) return null; const platformGuides: Record<string, CloudPlatformGuide> = { railway: { name: 'Railway', troubleshooting: [ '1. Check Railway environment variables are set', '2. Verify deployment logs in Railway dashboard', '3. Ensure PORT matches Railway assigned port (automatic)', '4. Check networking configuration for external access' ] }, render: { name: 'Render', troubleshooting: [ '1. Verify Render environment variables', '2. Check Render logs for startup errors', '3. Ensure health check endpoint is responding', '4. Verify instance type has sufficient resources' ] }, fly: { name: 'Fly.io', troubleshooting: [ '1. Check Fly.io logs: flyctl logs', '2. Verify fly.toml configuration', '3. Ensure volumes are properly mounted', '4. Check app status: flyctl status' ] }, heroku: { name: 'Heroku', troubleshooting: [ '1. Check Heroku logs: heroku logs --tail', '2. Verify Procfile configuration', '3. Ensure dynos are running: heroku ps', '4. Check environment variables: heroku config' ] }, kubernetes: { name: 'Kubernetes', troubleshooting: [ '1. Check pod logs: kubectl logs <pod-name>', '2. Verify service and ingress configuration', '3. Check persistent volume claims', '4. Verify resource limits and requests' ] }, aws: { name: 'AWS', troubleshooting: [ '1. Check CloudWatch logs', '2. Verify IAM roles and permissions', '3. Check security groups and networking', '4. Verify environment variables in service config' ] } }; return platformGuides[cloudPlatform] || { name: cloudPlatform.toUpperCase(), troubleshooting: [ '1. Check cloud platform logs', '2. Verify environment variables are set', '3. Check networking and port configuration', '4. Review platform-specific documentation' ] }; } // Handler: n8n_diagnostic export async function handleDiagnostic(request: any, context?: InstanceContext): Promise<McpToolResponse> { const startTime = Date.now(); const verbose = request.params?.arguments?.verbose || false; // Detect environment for targeted debugging const mcpMode = process.env.MCP_MODE || 'stdio'; const isDocker = process.env.IS_DOCKER === 'true'; const cloudPlatform = detectCloudPlatform(); // Check environment variables const envVars = { N8N_API_URL: process.env.N8N_API_URL || null, N8N_API_KEY: process.env.N8N_API_KEY ? '***configured***' : null, NODE_ENV: process.env.NODE_ENV || 'production', MCP_MODE: mcpMode, isDocker, cloudPlatform, nodeVersion: process.version, platform: process.platform }; // Check API configuration const apiConfig = getN8nApiConfig(); const apiConfigured = apiConfig !== null; const apiClient = getN8nApiClient(context); // Test API connectivity if configured let apiStatus = { configured: apiConfigured, connected: false, error: null as string | null, version: null as string | null }; if (apiClient) { try { const health = await apiClient.healthCheck(); apiStatus.connected = true; apiStatus.version = health.n8nVersion || 'unknown'; } catch (error) { apiStatus.error = error instanceof Error ? error.message : 'Unknown error'; } } // Check which tools are available const documentationTools = 22; // Base documentation tools const managementTools = apiConfigured ? 16 : 0; const totalTools = documentationTools + managementTools; // Check npm version const versionCheck = await checkNpmVersion(); // Get performance metrics const cacheMetricsData = getInstanceCacheMetrics(); const responseTime = Date.now() - startTime; // Build diagnostic report const diagnostic: DiagnosticResponseData = { timestamp: new Date().toISOString(), environment: envVars, apiConfiguration: { configured: apiConfigured, status: apiStatus, config: apiConfig ? { baseUrl: apiConfig.baseUrl, timeout: apiConfig.timeout, maxRetries: apiConfig.maxRetries } : null }, versionInfo: { current: versionCheck.currentVersion, latest: versionCheck.latestVersion, upToDate: !versionCheck.isOutdated, message: formatVersionMessage(versionCheck), ...(versionCheck.updateCommand ? { updateCommand: versionCheck.updateCommand } : {}) }, toolsAvailability: { documentationTools: { count: documentationTools, enabled: true, description: 'Always available - node info, search, validation, etc.' }, managementTools: { count: managementTools, enabled: apiConfigured, description: apiConfigured ? 'Management tools are ENABLED - create, update, execute workflows' : 'Management tools are DISABLED - configure N8N_API_URL and N8N_API_KEY to enable' }, totalAvailable: totalTools }, performance: { diagnosticResponseTimeMs: responseTime, cacheHitRate: (cacheMetricsData.hits + cacheMetricsData.misses) > 0 ? ((cacheMetricsData.hits / (cacheMetricsData.hits + cacheMetricsData.misses)) * 100).toFixed(2) + '%' : 'N/A', cachedInstances: cacheMetricsData.size }, modeSpecificDebug: getModeSpecificDebug(mcpMode) }; // Enhanced guidance based on telemetry insights if (apiConfigured && apiStatus.connected) { // API is working - provide next steps diagnostic.nextSteps = { message: '✓ API connected! Here\'s what you can do:', recommended: [ { action: 'n8n_list_workflows', description: 'See your existing workflows', timing: 'Fast (6 seconds median)' }, { action: 'n8n_create_workflow', description: 'Create a new workflow', timing: 'Typically 6-14 minutes to build' }, { action: 'search_nodes', description: 'Discover available nodes', timing: 'Fast - explore 500+ nodes' }, { action: 'search_templates', description: 'Browse pre-built workflows', timing: 'Find examples quickly' } ], tips: [ '82% of users start creating workflows after diagnostics - you\'re ready to go!', 'Most common first action: n8n_update_partial_workflow (managing existing workflows)', 'Use n8n_validate_workflow before deploying to catch issues early' ] }; } else if (apiConfigured && !apiStatus.connected) { // API configured but not connecting - troubleshooting diagnostic.troubleshooting = { issue: '⚠️ API configured but connection failed', error: apiStatus.error, steps: [ '1. Verify n8n instance is running and accessible', '2. Check N8N_API_URL is correct (currently: ' + apiConfig?.baseUrl + ')', '3. Test URL in browser: ' + apiConfig?.baseUrl + '/healthz', '4. Verify N8N_API_KEY has proper permissions', '5. Check firewall/network settings if using remote n8n', '6. Try running n8n_health_check again after fixes' ], commonIssues: [ 'Wrong port number in N8N_API_URL', 'API key doesn\'t have sufficient permissions', 'n8n instance not running or crashed', 'Network firewall blocking connection' ], documentation: 'https://github.com/czlonkowski/n8n-mcp?tab=readme-ov-file#n8n-management-tools-optional---requires-api-configuration' }; } else { // API not configured - setup guidance diagnostic.setupGuide = { message: 'n8n API not configured. You can still use documentation tools!', whatYouCanDoNow: { documentation: [ { tool: 'search_nodes', description: 'Search 500+ n8n nodes', example: 'search_nodes({query: "slack"})' }, { tool: 'get_node_essentials', description: 'Get node configuration details', example: 'get_node_essentials({nodeType: "nodes-base.httpRequest"})' }, { tool: 'search_templates', description: 'Browse workflow templates', example: 'search_templates({query: "chatbot"})' }, { tool: 'validate_workflow', description: 'Validate workflow JSON', example: 'validate_workflow({workflow: {...}})' } ], note: '22 documentation tools available without API configuration' }, whatYouCannotDo: [ '✗ Create/update workflows in n8n instance', '✗ List your workflows', '✗ Execute workflows', '✗ View execution results' ], howToEnable: { steps: [ '1. Get your n8n API key: [Your n8n instance]/settings/api', '2. Set environment variables:', ' N8N_API_URL=https://your-n8n-instance.com', ' N8N_API_KEY=your_api_key_here', '3. Restart the MCP server', '4. Run n8n_diagnostic again to verify', '5. All 38 tools will be available!' ], documentation: 'https://github.com/czlonkowski/n8n-mcp?tab=readme-ov-file#n8n-management-tools-optional---requires-api-configuration' } }; } // Add version warning if outdated if (versionCheck.isOutdated && versionCheck.latestVersion) { diagnostic.updateWarning = { message: `⚠️ Update available: v${versionCheck.currentVersion} → v${versionCheck.latestVersion}`, command: versionCheck.updateCommand, benefits: [ 'Latest bug fixes and improvements', 'New features and tools', 'Better performance and reliability' ] }; } // Add Docker-specific debugging if in container const dockerDebug = getDockerDebug(isDocker); if (dockerDebug) { diagnostic.dockerDebug = dockerDebug; } // Add cloud platform-specific debugging if detected const cloudDebug = getCloudPlatformDebug(cloudPlatform); if (cloudDebug) { diagnostic.cloudPlatformDebug = cloudDebug; } // Add verbose debug info if requested if (verbose) { diagnostic.debug = { processEnv: Object.keys(process.env).filter(key => key.startsWith('N8N_') || key.startsWith('MCP_') ), nodeVersion: process.version, platform: process.platform, workingDirectory: process.cwd(), cacheMetrics: cacheMetricsData }; } // Track diagnostic usage with result data telemetry.trackEvent('diagnostic_completed', { success: true, apiConfigured, apiConnected: apiStatus.connected, toolsAvailable: totalTools, responseTimeMs: responseTime, upToDate: !versionCheck.isOutdated, verbose }); return { success: true, data: diagnostic }; } ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-comprehensive.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import { WorkflowValidator } from '@/services/workflow-validator'; import { NodeRepository } from '@/database/node-repository'; import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; import { ExpressionValidator } from '@/services/expression-validator'; import { createWorkflow } from '@tests/utils/builders/workflow.builder'; import type { WorkflowNode, Workflow } from '@/types/n8n-api'; // Mock dependencies vi.mock('@/database/node-repository'); vi.mock('@/services/enhanced-config-validator'); vi.mock('@/services/expression-validator'); vi.mock('@/utils/logger'); describe('WorkflowValidator - Comprehensive Tests', () => { let validator: WorkflowValidator; let mockNodeRepository: NodeRepository; let mockEnhancedConfigValidator: typeof EnhancedConfigValidator; beforeEach(() => { vi.clearAllMocks(); // Create mock instances mockNodeRepository = new NodeRepository({} as any) as any; mockEnhancedConfigValidator = EnhancedConfigValidator as any; // Ensure the mock repository has all necessary methods if (!mockNodeRepository.getAllNodes) { mockNodeRepository.getAllNodes = vi.fn(); } if (!mockNodeRepository.getNode) { mockNodeRepository.getNode = vi.fn(); } // Mock common node types data const nodeTypes: Record<string, any> = { 'nodes-base.webhook': { type: 'nodes-base.webhook', displayName: 'Webhook', package: 'n8n-nodes-base', version: 2, isVersioned: true, properties: [], category: 'trigger' }, 'nodes-base.httpRequest': { type: 'nodes-base.httpRequest', displayName: 'HTTP Request', package: 'n8n-nodes-base', version: 4, isVersioned: true, properties: [], category: 'network' }, 'nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', package: 'n8n-nodes-base', version: 3, isVersioned: true, properties: [], category: 'data' }, 'nodes-base.code': { type: 'nodes-base.code', displayName: 'Code', package: 'n8n-nodes-base', version: 2, isVersioned: true, properties: [], category: 'code' }, 'nodes-base.manualTrigger': { type: 'nodes-base.manualTrigger', displayName: 'Manual Trigger', package: 'n8n-nodes-base', version: 1, isVersioned: true, properties: [], category: 'trigger' }, 'nodes-base.if': { type: 'nodes-base.if', displayName: 'IF', package: 'n8n-nodes-base', version: 2, isVersioned: true, properties: [], category: 'logic' }, 'nodes-base.slack': { type: 'nodes-base.slack', displayName: 'Slack', package: 'n8n-nodes-base', version: 2, isVersioned: true, properties: [], category: 'communication' }, 'nodes-base.googleSheets': { type: 'nodes-base.googleSheets', displayName: 'Google Sheets', package: 'n8n-nodes-base', version: 4, isVersioned: true, properties: [], category: 'data' }, 'nodes-langchain.agent': { type: 'nodes-langchain.agent', displayName: 'AI Agent', package: '@n8n/n8n-nodes-langchain', version: 1, isVersioned: true, properties: [], isAITool: true, category: 'ai' }, 'nodes-base.postgres': { type: 'nodes-base.postgres', displayName: 'Postgres', package: 'n8n-nodes-base', version: 2, isVersioned: true, properties: [], category: 'database' }, 'community.customNode': { type: 'community.customNode', displayName: 'Custom Node', package: 'n8n-nodes-custom', version: 1, isVersioned: false, properties: [], isAITool: false, category: 'custom' } }; // Set up default mock behaviors vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => { // Handle normalization for custom nodes if (nodeType === 'n8n-nodes-custom.customNode') { return { type: 'n8n-nodes-custom.customNode', displayName: 'Custom Node', package: 'n8n-nodes-custom', version: 1, isVersioned: false, properties: [], isAITool: false }; } return nodeTypes[nodeType] || null; }); // Mock getAllNodes for NodeSimilarityService vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes)); vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ errors: [], warnings: [], suggestions: [], mode: 'operation' as const, valid: true, visibleProperties: [], hiddenProperties: [] } as any); vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ valid: true, errors: [], warnings: [], usedVariables: new Set(), usedNodes: new Set() }); // Create validator instance validator = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator); }); describe('validateWorkflow', () => { it('should validate a minimal valid workflow', async () => { const workflow = createWorkflow('Test Workflow') .addWebhookNode({ name: 'Webhook' }) .build(); const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); expect(result.statistics.totalNodes).toBe(1); expect(result.statistics.enabledNodes).toBe(1); expect(result.statistics.triggerNodes).toBe(1); }); it('should validate a workflow with all options disabled', async () => { const workflow = createWorkflow('Test Workflow') .addWebhookNode({ name: 'Webhook' }) .build(); const result = await validator.validateWorkflow(workflow as any, { validateNodes: false, validateConnections: false, validateExpressions: false }); expect(result.valid).toBe(true); expect(mockNodeRepository.getNode).not.toHaveBeenCalled(); expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled(); }); it('should handle validation errors gracefully', async () => { const workflow = createWorkflow('Test Workflow') .addWebhookNode({ name: 'Webhook' }) .build(); // Make the validation throw an error vi.mocked(mockNodeRepository.getNode).mockImplementation(() => { throw new Error('Database error'); }); const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors.some(e => e.message.includes('Database error'))).toBe(true); }); it('should use different validation profiles', async () => { const workflow = createWorkflow('Test Workflow') .addWebhookNode({ name: 'Webhook' }) .build(); const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict'] as const; for (const profile of profiles) { const result = await validator.validateWorkflow(workflow as any, { profile }); expect(result).toBeDefined(); expect(mockEnhancedConfigValidator.validateWithMode).toHaveBeenCalledWith( expect.any(String), expect.any(Object), expect.any(Array), 'operation', profile ); } }); }); describe('validateWorkflowStructure', () => { it('should error when nodes array is missing', async () => { const workflow = { connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true); }); it('should error when connections object is missing', async () => { const workflow = { nodes: [] } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true); }); it('should warn when workflow has no nodes', async () => { const workflow = { nodes: [], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(true); // Empty workflows are valid but get a warning expect(result.warnings).toHaveLength(1); expect(result.warnings[0].message).toBe('Workflow is empty - no nodes defined'); }); it('should error for single non-webhook node workflow', async () => { const workflow = { nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true); }); it('should warn for webhook without connections', async () => { const workflow = { nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 2 }], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(true); expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true); }); it('should error for multi-node workflow without connections', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true); }); it('should detect duplicate node names', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [300, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true); }); it('should detect duplicate node IDs', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook1', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '1', name: 'Webhook2', type: 'n8n-nodes-base.webhook', position: [300, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true); }); it('should count trigger nodes correctly', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Schedule', type: 'n8n-nodes-base.scheduleTrigger', position: [100, 300], parameters: {} }, { id: '3', name: 'Manual', type: 'n8n-nodes-base.manualTrigger', position: [100, 500], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.statistics.triggerNodes).toBe(3); }); it('should warn when no trigger nodes exist', async () => { const workflow = { nodes: [ { id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }, { id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [300, 100], parameters: {} } ], connections: { 'Set': { main: [[{ node: 'Code', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true); }); it('should not count disabled nodes in enabledNodes count', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, disabled: true }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.statistics.totalNodes).toBe(2); expect(result.statistics.enabledNodes).toBe(1); }); }); describe('validateAllNodes', () => { it('should skip disabled nodes', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, disabled: true } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(mockNodeRepository.getNode).not.toHaveBeenCalled(); }); it('should accept both nodes-base and n8n-nodes-base prefixes as valid', async () => { // This test verifies the fix for false positives - both prefixes are valid const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'nodes-base.webhook', // This is now valid (normalized internally) position: [100, 100], parameters: {} } ], connections: {} } as any; // Mock the normalized node lookup (mockNodeRepository.getNode as any) = vi.fn((type: string) => { if (type === 'nodes-base.webhook') { return { nodeType: 'nodes-base.webhook', displayName: 'Webhook', properties: [], isVersioned: false }; } return null; }); const result = await validator.validateWorkflow(workflow as any); // Should NOT error for nodes-base prefix - it's valid! expect(result.valid).toBe(true); expect(result.errors.some(e => e.message.includes('Invalid node type'))).toBe(false); }); it.skip('should handle unknown node types with suggestions', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'httpRequest', // Missing package prefix position: [100, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('Unknown node type: "httpRequest"'))).toBe(true); expect(result.errors.some(e => e.message.includes('Did you mean "n8n-nodes-base.httpRequest"?'))).toBe(true); }); it('should try normalized types for n8n-nodes-base', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook'); }); it('should validate typeVersion but skip parameter validation for langchain nodes', async () => { const workflow = { nodes: [ { id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1, position: [100, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); // After v2.17.4 fix: Langchain nodes SHOULD call getNode for typeVersion validation // This prevents invalid typeVersion values from bypassing validation // But they skip parameter validation (handled by dedicated AI validators) expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent'); // Should not have typeVersion validation errors (other AI-specific errors may exist) const typeVersionErrors = result.errors.filter(e => e.message.includes('typeVersion')); expect(typeVersionErrors).toEqual([]); }); it('should catch invalid typeVersion for langchain nodes (v2.17.4 bug fix)', async () => { const workflow = { nodes: [ { id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 99999, // Invalid - exceeds maximum position: [100, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); // Critical: Before v2.17.4, this would pass validation but fail at runtime // After v2.17.4: Invalid typeVersion is caught during validation expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('typeVersion 99999 exceeds maximum') )).toBe(true); }); it('should validate typeVersion for versioned nodes', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} // Missing typeVersion } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true); }); it('should error for invalid typeVersion', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 'invalid' as any } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true); }); it('should warn for outdated typeVersion', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 1 // Current version is 2 } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true); }); it('should error for typeVersion exceeding maximum', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 10 // Max is 2 } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true); }); it('should add node validation errors and warnings', async () => { vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }], warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }], suggestions: [], mode: 'operation' as const, valid: false, visibleProperties: [], hiddenProperties: [] } as any); const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, typeVersion: 4 } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true); expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true); }); it('should handle node validation failures gracefully', async () => { vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockImplementation(() => { throw new Error('Validation error'); }); const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, typeVersion: 4 } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true); }); }); describe('validateConnections', () => { it('should validate valid connections', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.statistics.validConnections).toBe(1); expect(result.statistics.invalidConnections).toBe(0); }); it('should error for connection from non-existent node', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} } ], connections: { 'NonExistent': { main: [[{ node: 'Webhook', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true); expect(result.statistics.invalidConnections).toBe(1); }); it('should error when using node ID instead of name in source', async () => { const workflow = { nodes: [ { id: 'webhook-id', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: 'set-id', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: { 'webhook-id': { // Using ID instead of name main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Connection uses node ID \'webhook-id\' instead of node name \'Webhook\''))).toBe(true); }); it('should error for connection to non-existent node', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} } ], connections: { 'Webhook': { main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true); expect(result.statistics.invalidConnections).toBe(1); }); it('should error when using node ID instead of name in target', async () => { const workflow = { nodes: [ { id: 'webhook-id', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: 'set-id', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: { 'Webhook': { main: [[{ node: 'set-id', type: 'main', index: 0 }]] // Using ID instead of name } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Connection target uses node ID \'set-id\' instead of node name \'Set\''))).toBe(true); }); it('should warn for connection to disabled node', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {}, disabled: true } ], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true); }); it('should validate error outputs', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, { id: '2', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: { 'HTTP': { error: [[{ node: 'Error Handler', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.statistics.validConnections).toBe(1); }); it('should validate AI tool connections', async () => { const workflow = { nodes: [ { id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 100], parameters: {} }, { id: '2', name: 'Tool', type: 'n8n-nodes-base.httpRequest', position: [300, 100], parameters: {} } ], connections: { 'Agent': { ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.statistics.validConnections).toBe(1); }); it('should warn for community nodes used as AI tools', async () => { const workflow = { nodes: [ { id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 100], parameters: {}, typeVersion: 1 }, { id: '2', name: 'CustomTool', type: 'n8n-nodes-custom.customNode', position: [300, 100], parameters: {}, typeVersion: 1 } ], connections: { 'Agent': { ai_tool: [[{ node: 'CustomTool', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Community node "CustomTool" is being used as an AI tool'))).toBe(true); }); it('should warn for orphaned nodes', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, { id: '3', name: 'Orphaned', type: 'n8n-nodes-base.code', position: [500, 100], parameters: {} } ], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Node is not connected to any other nodes') && w.nodeName === 'Orphaned')).toBe(true); }); it('should detect cycles in workflow', async () => { const workflow = { nodes: [ { id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }, { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, { id: '3', name: 'Node3', type: 'n8n-nodes-base.set', position: [500, 100], parameters: {} } ], connections: { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: 0 }]] }, 'Node2': { main: [[{ node: 'Node3', type: 'main', index: 0 }]] }, 'Node3': { main: [[{ node: 'Node1', type: 'main', index: 0 }]] // Creates cycle } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true); }); it('should handle null connections properly', async () => { const workflow = { nodes: [ { id: '1', name: 'IF', type: 'n8n-nodes-base.if', position: [100, 100], parameters: {}, typeVersion: 2 }, { id: '2', name: 'True Branch', type: 'n8n-nodes-base.set', position: [300, 50], parameters: {}, typeVersion: 3 } ], connections: { 'IF': { main: [ [{ node: 'True Branch', type: 'main', index: 0 }], null // False branch not connected ] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.statistics.validConnections).toBe(1); expect(result.valid).toBe(true); }); }); describe('validateExpressions', () => { it('should validate expressions in node parameters', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: { values: { string: [ { name: 'field', value: '={{ $json.data }}' } ] } } } ], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith( expect.objectContaining({ values: expect.any(Object) }), expect.objectContaining({ availableNodes: expect.arrayContaining(['Webhook']), currentNodeName: 'Set', hasInputData: true }) ); }); it('should add expression errors to result', async () => { vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ valid: false, errors: ['Invalid expression syntax'], warnings: ['Deprecated variable usage'], usedVariables: new Set(['$json']), usedNodes: new Set() }); const workflow = { nodes: [ { id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: { value: '={{ invalid }}' } } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true); expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true); expect(result.statistics.expressionsValidated).toBe(1); }); it('should skip expression validation for disabled nodes', async () => { const workflow = { nodes: [ { id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: { value: '={{ $json.data }}' }, disabled: true } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled(); }); }); describe('checkWorkflowPatterns', () => { it('should suggest error handling for large workflows', async () => { const builder = createWorkflow('Large Workflow'); // Add more than 3 nodes for (let i = 0; i < 5; i++) { builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Set${i}` }); } const workflow = builder.build() as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true); }); it('should warn about long linear chains', async () => { const builder = createWorkflow('Linear Workflow'); // Create a chain of 12 nodes const nodeNames: string[] = []; for (let i = 0; i < 12; i++) { const nodeName = `Node${i}`; builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: nodeName }); nodeNames.push(nodeName); } // Connect them sequentially builder.connectSequentially(nodeNames); const workflow = builder.build() as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true); }); it('should warn about missing credentials', async () => { const workflow = { nodes: [ { id: '1', name: 'Slack', type: 'n8n-nodes-base.slack', position: [100, 100], parameters: {}, credentials: { slackApi: {} // Missing id } } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Missing credentials configuration for slackApi'))).toBe(true); }); it('should warn about AI agents without tools', async () => { const workflow = { nodes: [ { id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true); }); it('should suggest community package setting for AI tools', async () => { const workflow = { nodes: [ { id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 100], parameters: {} }, { id: '2', name: 'Tool', type: 'n8n-nodes-base.httpRequest', position: [300, 100], parameters: {} } ], connections: { 'Agent': { ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE'))).toBe(true); }); }); describe('checkNodeErrorHandling', () => { it('should error when node-level properties are inside parameters', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], typeVersion: 4, parameters: { url: 'https://api.example.com', onError: 'continueRegularOutput', // Wrong location! retryOnFail: true, // Wrong location! credentials: {} // Wrong location! } } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true); expect(result.errors.some(e => e.details?.fix?.includes('Move these properties from node.parameters to the node level'))).toBe(true); }); it('should validate onError property values', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'invalidValue' as any } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true); }); it('should warn about deprecated continueOnFail', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, continueOnFail: true } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true); }); it('should error for conflicting error handling properties', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, continueOnFail: true, onError: 'continueRegularOutput' } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true); }); it('should validate retry configuration', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, retryOnFail: true, maxTries: 'invalid' as any, waitBetweenTries: -1000 } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true); expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true); }); it('should warn about excessive retry values', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, retryOnFail: true, maxTries: 15, waitBetweenTries: 400000 } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('maxTries is set to 15'))).toBe(true); expect(result.warnings.some(w => w.message.includes('waitBetweenTries is set to 400000ms'))).toBe(true); }); it('should warn about retryOnFail without maxTries', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, retryOnFail: true } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('retryOnFail is enabled but maxTries is not specified'))).toBe(true); }); it('should validate other node-level properties', async () => { const workflow = { nodes: [ { id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {}, typeVersion: 3, alwaysOutputData: 'invalid' as any, executeOnce: 'invalid' as any, disabled: 'invalid' as any, notesInFlow: 'invalid' as any, notes: 123 as any } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true); expect(result.errors.some(e => e.message.includes('executeOnce must be a boolean'))).toBe(true); expect(result.errors.some(e => e.message.includes('disabled must be a boolean'))).toBe(true); expect(result.errors.some(e => e.message.includes('notesInFlow must be a boolean'))).toBe(true); expect(result.errors.some(e => e.message.includes('notes must be a string'))).toBe(true); }); it('should warn about executeOnce', async () => { const workflow = { nodes: [ { id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {}, executeOnce: true } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('executeOnce is enabled'))).toBe(true); }); it('should warn error-prone nodes without error handling', async () => { const errorProneNodes = [ { type: 'n8n-nodes-base.httpRequest', message: 'HTTP Request', version: 4 }, { type: 'n8n-nodes-base.webhook', message: 'Webhook', version: 2 }, { type: 'n8n-nodes-base.postgres', message: 'Database operation', version: 2 }, { type: 'n8n-nodes-base.slack', message: 'slack node', version: 2 } ]; for (const nodeInfo of errorProneNodes) { const workflow = { nodes: [ { id: '1', name: 'Node', type: nodeInfo.type, position: [100, 100], parameters: {}, typeVersion: nodeInfo.version } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes(nodeInfo.message) && w.message.includes('without error handling'))).toBe(true); } }); it('should warn about conflicting error handling', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, continueOnFail: true, retryOnFail: true } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('Both continueOnFail and retryOnFail are enabled'))).toBe(true); }); it('should suggest alwaysOutputData for debugging', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, retryOnFail: true } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('Consider enabling alwaysOutputData'))).toBe(true); }); it('should provide general error handling suggestions', async () => { const builder = createWorkflow('No Error Handling'); // Add 6 nodes without error handling for (let i = 0; i < 6; i++) { builder.addCustomNode('n8n-nodes-base.httpRequest', 4, {}, { name: `HTTP${i}` }); } const workflow = builder.build() as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('Most nodes lack error handling'))).toBe(true); }); it('should suggest replacing deprecated error handling', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, continueOnFail: true } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('Replace "continueOnFail: true" with "onError:'))).toBe(true); }); }); describe('generateSuggestions', () => { it('should suggest adding trigger for workflows without triggers', async () => { const workflow = { nodes: [ { id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true); }); it('should provide connection examples for connection errors', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: {} // Missing connections } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true); expect(result.suggestions.some(s => s.includes('Use node NAMES (not IDs) in connections'))).toBe(true); }); it('should suggest error handling when missing', async () => { const workflow = { nodes: [ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [300, 100], parameters: {} } ], connections: { 'Webhook': { main: [[{ node: 'HTTP', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('Add error handling'))).toBe(true); }); it('should suggest breaking up large workflows', async () => { const builder = createWorkflow('Large Workflow'); // Add 25 nodes for (let i = 0; i < 25; i++) { builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Node${i}` }); } const workflow = builder.build() as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('Consider breaking this workflow into smaller sub-workflows'))).toBe(true); }); it('should suggest Code node for complex expressions', async () => { const workflow = { nodes: [ { id: '1', name: 'Complex', type: 'n8n-nodes-base.set', position: [100, 100], parameters: { field1: '={{ $json.a }}', field2: '={{ $json.b }}', field3: '={{ $json.c }}', field4: '={{ $json.d }}', field5: '={{ $json.e }}', field6: '={{ $json.f }}' } } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('Consider using a Code node for complex data transformations'))).toBe(true); }); it('should suggest minimal workflow structure', async () => { const workflow = { nodes: [ { id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} } ], connections: {} } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.suggestions.some(s => s.includes('A minimal workflow needs'))).toBe(true); }); }); describe('findSimilarNodeTypes', () => { it.skip('should find similar node types for common mistakes', async () => { // Test that webhook without prefix gets suggestions const webhookWorkflow = { nodes: [ { id: '1', name: 'Node', type: 'webhook', position: [100, 100], parameters: {} } ], connections: {} } as any; const webhookResult = await validator.validateWorkflow(webhookWorkflow); // Check that we get an unknown node error with suggestions const unknownNodeError = webhookResult.errors.find(e => e.message && e.message.includes('Unknown node type') ); expect(unknownNodeError).toBeDefined(); // For webhook, it should definitely suggest nodes-base.webhook expect(unknownNodeError?.message).toContain('nodes-base.webhook'); // Test that slack without prefix gets suggestions const slackWorkflow = { nodes: [ { id: '1', name: 'Node', type: 'slack', position: [100, 100], parameters: {} } ], connections: {} } as any; const slackResult = await validator.validateWorkflow(slackWorkflow); const slackError = slackResult.errors.find(e => e.message && e.message.includes('Unknown node type') ); expect(slackError).toBeDefined(); expect(slackError?.message).toContain('nodes-base.slack'); }); }); describe('Integration Tests', () => { it('should validate a complex workflow with multiple issues', async () => { const workflow = { nodes: [ // Valid trigger { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 2 }, // Node with valid alternative prefix (no longer an error) { id: '2', name: 'HTTP1', type: 'nodes-base.httpRequest', // Valid prefix (normalized internally) position: [300, 100], parameters: {} }, // Node with missing typeVersion { id: '3', name: 'Slack', type: 'n8n-nodes-base.slack', position: [500, 100], parameters: {} }, // Disabled node { id: '4', name: 'Disabled', type: 'n8n-nodes-base.set', position: [700, 100], parameters: {}, disabled: true }, // Node with error handling in wrong place { id: '5', name: 'HTTP2', type: 'n8n-nodes-base.httpRequest', position: [900, 100], parameters: { onError: 'continueRegularOutput' }, typeVersion: 4 }, // Orphaned node { id: '6', name: 'Orphaned', type: 'n8n-nodes-base.code', position: [1100, 100], parameters: {}, typeVersion: 2 }, // AI Agent without tools { id: '7', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 300], parameters: {}, typeVersion: 1 } ], connections: { 'Webhook': { main: [[{ node: 'HTTP1', type: 'main', index: 0 }]] }, 'HTTP1': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] }, 'Slack': { main: [[{ node: 'Disabled', type: 'main', index: 0 }]] }, // Using ID instead of name '5': { main: [[{ node: 'Agent', type: 'main', index: 0 }]] } } } as any; const result = await validator.validateWorkflow(workflow as any); // Should have multiple errors (but not for the nodes-base prefix) expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThan(2); // Reduced by 1 since nodes-base prefix is now valid // Specific errors (removed the invalid node type error as it's no longer invalid) expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true); expect(result.errors.some(e => e.message.includes('Node-level properties onError are in the wrong location'))).toBe(true); expect(result.errors.some(e => e.message.includes('Connection uses node ID \'5\' instead of node name'))).toBe(true); // Warnings expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true); expect(result.warnings.some(w => w.message.includes('Node is not connected') && w.nodeName === 'Orphaned')).toBe(true); expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true); // Statistics expect(result.statistics.totalNodes).toBe(7); expect(result.statistics.enabledNodes).toBe(6); expect(result.statistics.triggerNodes).toBe(1); expect(result.statistics.invalidConnections).toBeGreaterThan(0); // Suggestions expect(result.suggestions.length).toBeGreaterThan(0); }); it('should validate a perfect workflow', async () => { const workflow = { nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [250, 300], parameters: {}, typeVersion: 1 }, { id: '2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [450, 300], parameters: { url: 'https://api.example.com/data', method: 'GET' }, typeVersion: 4, onError: 'continueErrorOutput', retryOnFail: true, maxTries: 3, waitBetweenTries: 1000 }, { id: '3', name: 'Process Data', type: 'n8n-nodes-base.code', position: [650, 300], parameters: { jsCode: 'return items;' }, typeVersion: 2 }, { id: '4', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [650, 500], parameters: { values: { string: [ { name: 'error', value: 'An error occurred' } ] } }, typeVersion: 3 } ], connections: { 'Manual Trigger': { main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] }, 'HTTP Request': { main: [ [{ node: 'Process Data', type: 'main', index: 0 }], [{ node: 'Error Handler', type: 'main', index: 0 }] ] } } } as any; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); expect(result.warnings).toHaveLength(0); expect(result.statistics.validConnections).toBe(3); expect(result.statistics.invalidConnections).toBe(0); }); }); }); ``` -------------------------------------------------------------------------------- /src/services/workflow-validator.ts: -------------------------------------------------------------------------------- ```typescript /** * Workflow Validator for n8n workflows * Validates complete workflow structure, connections, and node configurations */ import { NodeRepository } from '../database/node-repository'; import { EnhancedConfigValidator } from './enhanced-config-validator'; import { ExpressionValidator } from './expression-validator'; import { ExpressionFormatValidator } from './expression-format-validator'; import { NodeSimilarityService, NodeSuggestion } from './node-similarity-service'; import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; import { Logger } from '../utils/logger'; import { validateAISpecificNodes, hasAINodes } from './ai-node-validator'; const logger = new Logger({ prefix: '[WorkflowValidator]' }); interface WorkflowNode { id: string; name: string; type: string; position: [number, number]; parameters: any; credentials?: any; disabled?: boolean; notes?: string; notesInFlow?: boolean; typeVersion?: number; continueOnFail?: boolean; onError?: 'continueRegularOutput' | 'continueErrorOutput' | 'stopWorkflow'; retryOnFail?: boolean; maxTries?: number; waitBetweenTries?: number; alwaysOutputData?: boolean; executeOnce?: boolean; } interface WorkflowConnection { [sourceNode: string]: { main?: Array<Array<{ node: string; type: string; index: number }>>; error?: Array<Array<{ node: string; type: string; index: number }>>; ai_tool?: Array<Array<{ node: string; type: string; index: number }>>; }; } interface WorkflowJson { name?: string; nodes: WorkflowNode[]; connections: WorkflowConnection; settings?: any; staticData?: any; pinData?: any; meta?: any; } interface ValidationIssue { type: 'error' | 'warning'; nodeId?: string; nodeName?: string; message: string; details?: any; } export interface WorkflowValidationResult { valid: boolean; errors: ValidationIssue[]; warnings: ValidationIssue[]; statistics: { totalNodes: number; enabledNodes: number; triggerNodes: number; validConnections: number; invalidConnections: number; expressionsValidated: number; }; suggestions: string[]; } export class WorkflowValidator { private currentWorkflow: WorkflowJson | null = null; private similarityService: NodeSimilarityService; constructor( private nodeRepository: NodeRepository, private nodeValidator: typeof EnhancedConfigValidator ) { this.similarityService = new NodeSimilarityService(nodeRepository); } /** * Check if a node is a Sticky Note or other non-executable node */ private isStickyNote(node: WorkflowNode): boolean { const stickyNoteTypes = [ 'n8n-nodes-base.stickyNote', 'nodes-base.stickyNote', '@n8n/n8n-nodes-base.stickyNote' ]; return stickyNoteTypes.includes(node.type); } /** * Validate a complete workflow */ async validateWorkflow( workflow: WorkflowJson, options: { validateNodes?: boolean; validateConnections?: boolean; validateExpressions?: boolean; profile?: 'minimal' | 'runtime' | 'ai-friendly' | 'strict'; } = {} ): Promise<WorkflowValidationResult> { // Store current workflow for access in helper methods this.currentWorkflow = workflow; const { validateNodes = true, validateConnections = true, validateExpressions = true, profile = 'runtime' } = options; const result: WorkflowValidationResult = { valid: true, errors: [], warnings: [], statistics: { totalNodes: 0, enabledNodes: 0, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0, }, suggestions: [] }; try { // Handle null/undefined workflow if (!workflow) { result.errors.push({ type: 'error', message: 'Invalid workflow structure: workflow is null or undefined' }); result.valid = false; return result; } // Update statistics after null check (exclude sticky notes from counts) const executableNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(n => !this.isStickyNote(n)) : []; result.statistics.totalNodes = executableNodes.length; result.statistics.enabledNodes = executableNodes.filter(n => !n.disabled).length; // Basic workflow structure validation this.validateWorkflowStructure(workflow, result); // Only continue if basic structure is valid if (workflow.nodes && Array.isArray(workflow.nodes) && workflow.connections && typeof workflow.connections === 'object') { // Validate each node if requested if (validateNodes && workflow.nodes.length > 0) { await this.validateAllNodes(workflow, result, profile); } // Validate connections if requested if (validateConnections) { this.validateConnections(workflow, result, profile); } // Validate expressions if requested if (validateExpressions && workflow.nodes.length > 0) { this.validateExpressions(workflow, result, profile); } // Check workflow patterns and best practices if (workflow.nodes.length > 0) { this.checkWorkflowPatterns(workflow, result, profile); } // Validate AI-specific nodes (AI Agent, Chat Trigger, AI tools) if (workflow.nodes.length > 0 && hasAINodes(workflow)) { const aiIssues = validateAISpecificNodes(workflow); // Convert AI validation issues to workflow validation format for (const issue of aiIssues) { const validationIssue: ValidationIssue = { type: issue.severity === 'error' ? 'error' : 'warning', nodeId: issue.nodeId, nodeName: issue.nodeName, message: issue.message, details: issue.code ? { code: issue.code } : undefined }; if (issue.severity === 'error') { result.errors.push(validationIssue); } else { result.warnings.push(validationIssue); } } } // Add suggestions based on findings this.generateSuggestions(workflow, result); // Add AI-specific recovery suggestions if there are errors if (result.errors.length > 0) { this.addErrorRecoverySuggestions(result); } } } catch (error) { logger.error('Error validating workflow:', error); result.errors.push({ type: 'error', message: `Workflow validation failed: ${error instanceof Error ? error.message : 'Unknown error'}` }); } result.valid = result.errors.length === 0; return result; } /** * Validate basic workflow structure */ private validateWorkflowStructure( workflow: WorkflowJson, result: WorkflowValidationResult ): void { // Check for required fields if (!workflow.nodes) { result.errors.push({ type: 'error', message: workflow.nodes === null ? 'nodes must be an array' : 'Workflow must have a nodes array' }); return; } if (!Array.isArray(workflow.nodes)) { result.errors.push({ type: 'error', message: 'nodes must be an array' }); return; } if (!workflow.connections) { result.errors.push({ type: 'error', message: workflow.connections === null ? 'connections must be an object' : 'Workflow must have a connections object' }); return; } if (typeof workflow.connections !== 'object' || Array.isArray(workflow.connections)) { result.errors.push({ type: 'error', message: 'connections must be an object' }); return; } // Check for empty workflow - this should be a warning, not an error if (workflow.nodes.length === 0) { result.warnings.push({ type: 'warning', message: 'Workflow is empty - no nodes defined' }); return; } // Check for minimum viable workflow if (workflow.nodes.length === 1) { const singleNode = workflow.nodes[0]; const normalizedType = NodeTypeNormalizer.normalizeToFullForm(singleNode.type); const isWebhook = normalizedType === 'nodes-base.webhook' || normalizedType === 'nodes-base.webhookTrigger'; const isLangchainNode = normalizedType.startsWith('nodes-langchain.'); // Langchain nodes can be validated standalone for AI tool purposes if (!isWebhook && !isLangchainNode) { result.errors.push({ type: 'error', message: 'Single-node workflows are only valid for webhook endpoints. Add at least one more connected node to create a functional workflow.' }); } else if (isWebhook && Object.keys(workflow.connections).length === 0) { result.warnings.push({ type: 'warning', message: 'Webhook node has no connections. Consider adding nodes to process the webhook data.' }); } } // Check for empty connections in multi-node workflows if (workflow.nodes.length > 1) { const hasEnabledNodes = workflow.nodes.some(n => !n.disabled); const hasConnections = Object.keys(workflow.connections).length > 0; if (hasEnabledNodes && !hasConnections) { result.errors.push({ type: 'error', message: 'Multi-node workflow has no connections. Nodes must be connected to create a workflow. Use connections: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }' }); } } // Check for duplicate node names const nodeNames = new Set<string>(); const nodeIds = new Set<string>(); for (const node of workflow.nodes) { if (nodeNames.has(node.name)) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Duplicate node name: "${node.name}"` }); } nodeNames.add(node.name); if (nodeIds.has(node.id)) { result.errors.push({ type: 'error', nodeId: node.id, message: `Duplicate node ID: "${node.id}"` }); } nodeIds.add(node.id); } // Count trigger nodes - normalize type names first const triggerNodes = workflow.nodes.filter(n => { const normalizedType = NodeTypeNormalizer.normalizeToFullForm(n.type); const lowerType = normalizedType.toLowerCase(); return lowerType.includes('trigger') || (lowerType.includes('webhook') && !lowerType.includes('respond')) || normalizedType === 'nodes-base.start' || normalizedType === 'nodes-base.manualTrigger' || normalizedType === 'nodes-base.formTrigger'; }); result.statistics.triggerNodes = triggerNodes.length; // Check for at least one trigger node if (triggerNodes.length === 0 && workflow.nodes.filter(n => !n.disabled).length > 0) { result.warnings.push({ type: 'warning', message: 'Workflow has no trigger nodes. It can only be executed manually.' }); } } /** * Validate all nodes in the workflow */ private async validateAllNodes( workflow: WorkflowJson, result: WorkflowValidationResult, profile: string ): Promise<void> { for (const node of workflow.nodes) { if (node.disabled || this.isStickyNote(node)) continue; try { // Validate node name length if (node.name && node.name.length > 255) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Node name is very long (${node.name.length} characters). Consider using a shorter name for better readability.` }); } // Validate node position if (!Array.isArray(node.position) || node.position.length !== 2) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'Node position must be an array with exactly 2 numbers [x, y]' }); } else { const [x, y] = node.position; if (typeof x !== 'number' || typeof y !== 'number' || !isFinite(x) || !isFinite(y)) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'Node position values must be finite numbers' }); } } // Normalize node type FIRST to ensure consistent lookup const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type); // Update node type in place if it was normalized if (normalizedType !== node.type) { node.type = normalizedType; } // Get node definition using normalized type (needed for typeVersion validation) const nodeInfo = this.nodeRepository.getNode(normalizedType); if (!nodeInfo) { // Use NodeSimilarityService to find suggestions const suggestions = await this.similarityService.findSimilarNodes(node.type, 3); let message = `Unknown node type: "${node.type}".`; if (suggestions.length > 0) { message += '\n\nDid you mean one of these?'; for (const suggestion of suggestions) { const confidence = Math.round(suggestion.confidence * 100); message += `\n• ${suggestion.nodeType} (${confidence}% match)`; if (suggestion.displayName) { message += ` - ${suggestion.displayName}`; } message += `\n → ${suggestion.reason}`; if (suggestion.confidence >= 0.9) { message += ' (can be auto-fixed)'; } } } else { message += ' No similar nodes found. Node types must include the package prefix (e.g., "n8n-nodes-base.webhook").'; } const error: any = { type: 'error', nodeId: node.id, nodeName: node.name, message }; // Add suggestions as metadata for programmatic access if (suggestions.length > 0) { error.suggestions = suggestions.map(s => ({ nodeType: s.nodeType, confidence: s.confidence, reason: s.reason })); } result.errors.push(error); continue; } // Validate typeVersion for ALL versioned nodes (including langchain nodes) // CRITICAL: This MUST run BEFORE the langchain skip below! // Otherwise, langchain nodes with invalid typeVersion (e.g., 99999) would pass validation // but fail at runtime in n8n. This was the bug fixed in v2.17.4. if (nodeInfo.isVersioned) { // Check if typeVersion is missing if (!node.typeVersion) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Missing required property 'typeVersion'. Add typeVersion: ${nodeInfo.version || 1}` }); } // Check if typeVersion is invalid (must be non-negative number, version 0 is valid) else if (typeof node.typeVersion !== 'number' || node.typeVersion < 0) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Invalid typeVersion: ${node.typeVersion}. Must be a non-negative number` }); } // Check if typeVersion is outdated (less than latest) else if (nodeInfo.version && node.typeVersion < nodeInfo.version) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Outdated typeVersion: ${node.typeVersion}. Latest is ${nodeInfo.version}` }); } // Check if typeVersion exceeds maximum supported else if (nodeInfo.version && node.typeVersion > nodeInfo.version) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `typeVersion ${node.typeVersion} exceeds maximum supported version ${nodeInfo.version}` }); } } // Skip PARAMETER validation for langchain nodes (but NOT typeVersion validation above!) // Langchain nodes have dedicated AI-specific validators in validateAISpecificNodes() // which handle their unique parameter structures (AI connections, tool ports, etc.) if (normalizedType.startsWith('nodes-langchain.')) { continue; } // Validate node configuration const nodeValidation = this.nodeValidator.validateWithMode( node.type, node.parameters, nodeInfo.properties || [], 'operation', profile as any ); // Add node-specific errors and warnings nodeValidation.errors.forEach((error: any) => { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: typeof error === 'string' ? error : error.message || String(error) }); }); nodeValidation.warnings.forEach((warning: any) => { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: typeof warning === 'string' ? warning : warning.message || String(warning) }); }); } catch (error) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Failed to validate node: ${error instanceof Error ? error.message : 'Unknown error'}` }); } } } /** * Validate workflow connections */ private validateConnections( workflow: WorkflowJson, result: WorkflowValidationResult, profile: string = 'runtime' ): void { const nodeMap = new Map(workflow.nodes.map(n => [n.name, n])); const nodeIdMap = new Map(workflow.nodes.map(n => [n.id, n])); // Check all connections for (const [sourceName, outputs] of Object.entries(workflow.connections)) { const sourceNode = nodeMap.get(sourceName); if (!sourceNode) { // Check if this is an ID being used instead of a name const nodeById = nodeIdMap.get(sourceName); if (nodeById) { result.errors.push({ type: 'error', nodeId: nodeById.id, nodeName: nodeById.name, message: `Connection uses node ID '${sourceName}' instead of node name '${nodeById.name}'. In n8n, connections must use node names, not IDs.` }); } else { result.errors.push({ type: 'error', message: `Connection from non-existent node: "${sourceName}"` }); } result.statistics.invalidConnections++; continue; } // Check main outputs if (outputs.main) { this.validateConnectionOutputs( sourceName, outputs.main, nodeMap, nodeIdMap, result, 'main' ); } // Check error outputs if (outputs.error) { this.validateConnectionOutputs( sourceName, outputs.error, nodeMap, nodeIdMap, result, 'error' ); } // Check AI tool outputs if (outputs.ai_tool) { this.validateConnectionOutputs( sourceName, outputs.ai_tool, nodeMap, nodeIdMap, result, 'ai_tool' ); } } // Check for orphaned nodes (not connected and not triggers) const connectedNodes = new Set<string>(); // Add all source nodes Object.keys(workflow.connections).forEach(name => connectedNodes.add(name)); // Add all target nodes Object.values(workflow.connections).forEach(outputs => { if (outputs.main) { outputs.main.flat().forEach(conn => { if (conn) connectedNodes.add(conn.node); }); } if (outputs.error) { outputs.error.flat().forEach(conn => { if (conn) connectedNodes.add(conn.node); }); } if (outputs.ai_tool) { outputs.ai_tool.flat().forEach(conn => { if (conn) connectedNodes.add(conn.node); }); } }); // Check for orphaned nodes (exclude sticky notes) for (const node of workflow.nodes) { if (node.disabled || this.isStickyNote(node)) continue; const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type); const isTrigger = normalizedType.toLowerCase().includes('trigger') || normalizedType.toLowerCase().includes('webhook') || normalizedType === 'nodes-base.start' || normalizedType === 'nodes-base.manualTrigger' || normalizedType === 'nodes-base.formTrigger'; if (!connectedNodes.has(node.name) && !isTrigger) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'Node is not connected to any other nodes' }); } } // Check for cycles (skip in minimal profile to reduce false positives) if (profile !== 'minimal' && this.hasCycle(workflow)) { result.errors.push({ type: 'error', message: 'Workflow contains a cycle (infinite loop)' }); } } /** * Validate connection outputs */ private validateConnectionOutputs( sourceName: string, outputs: Array<Array<{ node: string; type: string; index: number }>>, nodeMap: Map<string, WorkflowNode>, nodeIdMap: Map<string, WorkflowNode>, result: WorkflowValidationResult, outputType: 'main' | 'error' | 'ai_tool' ): void { // Get source node for special validation const sourceNode = nodeMap.get(sourceName); // Special validation for main outputs with error handling if (outputType === 'main' && sourceNode) { this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result); } outputs.forEach((outputConnections, outputIndex) => { if (!outputConnections) return; outputConnections.forEach(connection => { // Check for negative index if (connection.index < 0) { result.errors.push({ type: 'error', message: `Invalid connection index ${connection.index} from "${sourceName}". Connection indices must be non-negative.` }); result.statistics.invalidConnections++; return; } // Special validation for SplitInBatches node if (sourceNode && sourceNode.type === 'nodes-base.splitInBatches') { this.validateSplitInBatchesConnection( sourceNode, outputIndex, connection, nodeMap, result ); } // Check for self-referencing connections if (connection.node === sourceName) { // This is only a warning for non-loop nodes if (sourceNode && sourceNode.type !== 'nodes-base.splitInBatches') { result.warnings.push({ type: 'warning', message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.` }); } } const targetNode = nodeMap.get(connection.node); if (!targetNode) { // Check if this is an ID being used instead of a name const nodeById = nodeIdMap.get(connection.node); if (nodeById) { result.errors.push({ type: 'error', nodeId: nodeById.id, nodeName: nodeById.name, message: `Connection target uses node ID '${connection.node}' instead of node name '${nodeById.name}' (from ${sourceName}). In n8n, connections must use node names, not IDs.` }); } else { result.errors.push({ type: 'error', message: `Connection to non-existent node: "${connection.node}" from "${sourceName}"` }); } result.statistics.invalidConnections++; } else if (targetNode.disabled) { result.warnings.push({ type: 'warning', message: `Connection to disabled node: "${connection.node}" from "${sourceName}"` }); } else { result.statistics.validConnections++; // Additional validation for AI tool connections if (outputType === 'ai_tool') { this.validateAIToolConnection(sourceName, targetNode, result); } } }); }); } /** * Validate error output configuration */ private validateErrorOutputConfiguration( sourceName: string, sourceNode: WorkflowNode, outputs: Array<Array<{ node: string; type: string; index: number }>>, nodeMap: Map<string, WorkflowNode>, result: WorkflowValidationResult ): void { // Check if node has onError: 'continueErrorOutput' const hasErrorOutputSetting = sourceNode.onError === 'continueErrorOutput'; const hasErrorConnections = outputs.length > 1 && outputs[1] && outputs[1].length > 0; // Validate mismatch between onError setting and connections if (hasErrorOutputSetting && !hasErrorConnections) { result.errors.push({ type: 'error', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `Node has onError: 'continueErrorOutput' but no error output connections in main[1]. Add error handler connections to main[1] or change onError to 'continueRegularOutput' or 'stopWorkflow'.` }); } if (!hasErrorOutputSetting && hasErrorConnections) { result.warnings.push({ type: 'warning', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `Node has error output connections in main[1] but missing onError: 'continueErrorOutput'. Add this property to properly handle errors.` }); } // Check for common mistake: multiple nodes in main[0] when error handling is intended if (outputs.length >= 1 && outputs[0] && outputs[0].length > 1) { // Check if any of the nodes in main[0] look like error handlers const potentialErrorHandlers = outputs[0].filter(conn => { const targetNode = nodeMap.get(conn.node); if (!targetNode) return false; const nodeName = targetNode.name.toLowerCase(); const nodeType = targetNode.type.toLowerCase(); // Common patterns for error handler nodes return nodeName.includes('error') || nodeName.includes('fail') || nodeName.includes('catch') || nodeName.includes('exception') || nodeType.includes('respondtowebhook') || nodeType.includes('emailsend'); }); if (potentialErrorHandlers.length > 0) { const errorHandlerNames = potentialErrorHandlers.map(conn => `"${conn.node}"`).join(', '); result.errors.push({ type: 'error', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `Incorrect error output configuration. Nodes ${errorHandlerNames} appear to be error handlers but are in main[0] (success output) along with other nodes.\n\n` + `INCORRECT (current):\n` + `"${sourceName}": {\n` + ` "main": [\n` + ` [ // main[0] has multiple nodes mixed together\n` + outputs[0].map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' + ` ]\n` + ` ]\n` + `}\n\n` + `CORRECT (should be):\n` + `"${sourceName}": {\n` + ` "main": [\n` + ` [ // main[0] = success output\n` + outputs[0].filter(conn => !potentialErrorHandlers.includes(conn)).map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' + ` ],\n` + ` [ // main[1] = error output\n` + potentialErrorHandlers.map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' + ` ]\n` + ` ]\n` + `}\n\n` + `Also add: "onError": "continueErrorOutput" to the "${sourceName}" node.` }); } } } /** * Validate AI tool connections */ private validateAIToolConnection( sourceName: string, targetNode: WorkflowNode, result: WorkflowValidationResult ): void { // For AI tool connections, we just need to check if this is being used as a tool // The source should be an AI Agent connecting to this target node as a tool // Get target node info to check if it can be used as a tool const normalizedType = NodeTypeNormalizer.normalizeToFullForm(targetNode.type); let targetNodeInfo = this.nodeRepository.getNode(normalizedType); // Try original type if normalization didn't help (fallback for edge cases) if (!targetNodeInfo && normalizedType !== targetNode.type) { targetNodeInfo = this.nodeRepository.getNode(targetNode.type); } if (targetNodeInfo && !targetNodeInfo.isAITool && targetNodeInfo.package !== 'n8n-nodes-base') { // It's a community node being used as a tool result.warnings.push({ type: 'warning', nodeId: targetNode.id, nodeName: targetNode.name, message: `Community node "${targetNode.name}" is being used as an AI tool. Ensure N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true is set.` }); } } /** * Check if workflow has cycles * Allow legitimate loops for SplitInBatches and similar loop nodes */ private hasCycle(workflow: WorkflowJson): boolean { const visited = new Set<string>(); const recursionStack = new Set<string>(); const nodeTypeMap = new Map<string, string>(); // Build node type map (exclude sticky notes) workflow.nodes.forEach(node => { if (!this.isStickyNote(node)) { nodeTypeMap.set(node.name, node.type); } }); // Known legitimate loop node types const loopNodeTypes = [ 'n8n-nodes-base.splitInBatches', 'nodes-base.splitInBatches', 'n8n-nodes-base.itemLists', 'nodes-base.itemLists', 'n8n-nodes-base.loop', 'nodes-base.loop' ]; const hasCycleDFS = (nodeName: string, pathFromLoopNode: boolean = false): boolean => { visited.add(nodeName); recursionStack.add(nodeName); const connections = workflow.connections[nodeName]; if (connections) { const allTargets: string[] = []; if (connections.main) { connections.main.flat().forEach(conn => { if (conn) allTargets.push(conn.node); }); } if (connections.error) { connections.error.flat().forEach(conn => { if (conn) allTargets.push(conn.node); }); } if (connections.ai_tool) { connections.ai_tool.flat().forEach(conn => { if (conn) allTargets.push(conn.node); }); } const currentNodeType = nodeTypeMap.get(nodeName); const isLoopNode = loopNodeTypes.includes(currentNodeType || ''); for (const target of allTargets) { if (!visited.has(target)) { if (hasCycleDFS(target, pathFromLoopNode || isLoopNode)) return true; } else if (recursionStack.has(target)) { // Allow cycles that involve legitimate loop nodes const targetNodeType = nodeTypeMap.get(target); const isTargetLoopNode = loopNodeTypes.includes(targetNodeType || ''); // If this cycle involves a loop node, it's legitimate if (isTargetLoopNode || pathFromLoopNode || isLoopNode) { continue; // Allow this cycle } return true; // Reject other cycles } } } recursionStack.delete(nodeName); return false; }; // Check from all executable nodes (exclude sticky notes) for (const node of workflow.nodes) { if (!this.isStickyNote(node) && !visited.has(node.name)) { if (hasCycleDFS(node.name)) return true; } } return false; } /** * Validate expressions in the workflow */ private validateExpressions( workflow: WorkflowJson, result: WorkflowValidationResult, profile: string = 'runtime' ): void { const nodeNames = workflow.nodes.map(n => n.name); for (const node of workflow.nodes) { if (node.disabled || this.isStickyNote(node)) continue; // Skip expression validation for langchain nodes // They have AI-specific validators and different expression rules const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type); if (normalizedType.startsWith('nodes-langchain.')) { continue; } // Create expression context const context = { availableNodes: nodeNames.filter(n => n !== node.name), currentNodeName: node.name, hasInputData: this.nodeHasInput(node.name, workflow), isInLoop: false // Could be enhanced to detect loop nodes }; // Validate expressions in parameters const exprValidation = ExpressionValidator.validateNodeExpressions( node.parameters, context ); // Count actual expressions found, not just unique variables const expressionCount = this.countExpressionsInObject(node.parameters); result.statistics.expressionsValidated += expressionCount; // Add expression errors and warnings exprValidation.errors.forEach(error => { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Expression error: ${error}` }); }); exprValidation.warnings.forEach(warning => { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Expression warning: ${warning}` }); }); // Validate expression format (check for missing = prefix and resource locator format) const formatContext = { nodeType: node.type, nodeName: node.name, nodeId: node.id }; const formatIssues = ExpressionFormatValidator.validateNodeParameters( node.parameters, formatContext ); // Add format errors and warnings formatIssues.forEach(issue => { const formattedMessage = ExpressionFormatValidator.formatErrorMessage(issue, formatContext); if (issue.severity === 'error') { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: formattedMessage }); } else { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: formattedMessage }); } }); } } /** * Count expressions in an object recursively */ private countExpressionsInObject(obj: any): number { let count = 0; if (typeof obj === 'string') { // Count expressions in string const matches = obj.match(/\{\{[\s\S]+?\}\}/g); if (matches) { count += matches.length; } } else if (Array.isArray(obj)) { // Recursively count in arrays for (const item of obj) { count += this.countExpressionsInObject(item); } } else if (obj && typeof obj === 'object') { // Recursively count in objects for (const value of Object.values(obj)) { count += this.countExpressionsInObject(value); } } return count; } /** * Check if a node has input connections */ private nodeHasInput(nodeName: string, workflow: WorkflowJson): boolean { for (const [sourceName, outputs] of Object.entries(workflow.connections)) { if (outputs.main) { for (const outputConnections of outputs.main) { if (outputConnections?.some(conn => conn.node === nodeName)) { return true; } } } } return false; } /** * Check workflow patterns and best practices */ private checkWorkflowPatterns( workflow: WorkflowJson, result: WorkflowValidationResult, profile: string = 'runtime' ): void { // Check for error handling (n8n uses main[1] for error outputs, not outputs.error) const hasErrorHandling = Object.values(workflow.connections).some( outputs => outputs.main && outputs.main.length > 1 && outputs.main[1] && outputs.main[1].length > 0 ); // Only suggest error handling in stricter profiles if (!hasErrorHandling && workflow.nodes.length > 3 && profile !== 'minimal') { result.warnings.push({ type: 'warning', message: 'Consider adding error handling to your workflow' }); } // Check node-level error handling properties for ALL executable nodes for (const node of workflow.nodes) { if (!this.isStickyNote(node)) { this.checkNodeErrorHandling(node, workflow, result); } } // Check for very long linear workflows const linearChainLength = this.getLongestLinearChain(workflow); if (linearChainLength > 10) { result.warnings.push({ type: 'warning', message: `Long linear chain detected (${linearChainLength} nodes). Consider breaking into sub-workflows.` }); } // Generate error handling suggestions based on all nodes this.generateErrorHandlingSuggestions(workflow, result); // Check for missing credentials for (const node of workflow.nodes) { if (node.credentials && Object.keys(node.credentials).length > 0) { for (const [credType, credConfig] of Object.entries(node.credentials)) { if (!credConfig || (typeof credConfig === 'object' && !('id' in credConfig))) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Missing credentials configuration for ${credType}` }); } } } } // Check for AI Agent workflows const aiAgentNodes = workflow.nodes.filter(n => n.type.toLowerCase().includes('agent') || n.type.includes('langchain.agent') ); if (aiAgentNodes.length > 0) { // Check if AI agents have tools connected for (const agentNode of aiAgentNodes) { const connections = workflow.connections[agentNode.name]; if (!connections?.ai_tool || connections.ai_tool.flat().filter(c => c).length === 0) { result.warnings.push({ type: 'warning', nodeId: agentNode.id, nodeName: agentNode.name, message: 'AI Agent has no tools connected. Consider adding tools to enhance agent capabilities.' }); } } // Check for community nodes used as tools const hasAIToolConnections = Object.values(workflow.connections).some( outputs => outputs.ai_tool && outputs.ai_tool.length > 0 ); if (hasAIToolConnections) { result.suggestions.push( 'For community nodes used as AI tools, ensure N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true is set' ); } } } /** * Get the longest linear chain in the workflow */ private getLongestLinearChain(workflow: WorkflowJson): number { const memo = new Map<string, number>(); const visiting = new Set<string>(); const getChainLength = (nodeName: string): number => { // If we're already visiting this node, we have a cycle if (visiting.has(nodeName)) return 0; if (memo.has(nodeName)) return memo.get(nodeName)!; visiting.add(nodeName); let maxLength = 0; const connections = workflow.connections[nodeName]; if (connections?.main) { for (const outputConnections of connections.main) { if (outputConnections) { for (const conn of outputConnections) { const length = getChainLength(conn.node); maxLength = Math.max(maxLength, length); } } } } visiting.delete(nodeName); const result = maxLength + 1; memo.set(nodeName, result); return result; }; let maxChain = 0; for (const node of workflow.nodes) { if (!this.nodeHasInput(node.name, workflow)) { maxChain = Math.max(maxChain, getChainLength(node.name)); } } return maxChain; } /** * Generate suggestions based on validation results */ private generateSuggestions( workflow: WorkflowJson, result: WorkflowValidationResult ): void { // Suggest adding trigger if missing if (result.statistics.triggerNodes === 0) { result.suggestions.push( 'Add a trigger node (e.g., Webhook, Schedule Trigger) to automate workflow execution' ); } // Suggest proper connection structure for workflows with connection errors const hasConnectionErrors = result.errors.some(e => typeof e.message === 'string' && ( e.message.includes('connection') || e.message.includes('Connection') || e.message.includes('Multi-node workflow has no connections') ) ); if (hasConnectionErrors) { result.suggestions.push( 'Example connection structure: connections: { "Manual Trigger": { "main": [[{ "node": "Set", "type": "main", "index": 0 }]] } }' ); result.suggestions.push( 'Remember: Use node NAMES (not IDs) in connections. The name is what you see in the UI, not the node type.' ); } // Suggest error handling if (!Object.values(workflow.connections).some(o => o.error)) { result.suggestions.push( 'Add error handling using the error output of nodes or an Error Trigger node' ); } // Suggest optimization for large workflows if (workflow.nodes.length > 20) { result.suggestions.push( 'Consider breaking this workflow into smaller sub-workflows for better maintainability' ); } // Suggest using Code node for complex logic const complexExpressionNodes = workflow.nodes.filter(node => { const jsonString = JSON.stringify(node.parameters); const expressionCount = (jsonString.match(/\{\{/g) || []).length; return expressionCount > 5; }); if (complexExpressionNodes.length > 0) { result.suggestions.push( 'Consider using a Code node for complex data transformations instead of multiple expressions' ); } // Suggest minimum workflow structure if (workflow.nodes.length === 1 && Object.keys(workflow.connections).length === 0) { result.suggestions.push( 'A minimal workflow needs: 1) A trigger node (e.g., Manual Trigger), 2) An action node (e.g., Set, HTTP Request), 3) A connection between them' ); } } /** * Check node-level error handling configuration for a single node */ private checkNodeErrorHandling( node: WorkflowNode, workflow: WorkflowJson, result: WorkflowValidationResult ): void { // Only skip if disabled is explicitly true (not just truthy) if (node.disabled === true) return; // Define node types that typically interact with external services (lowercase for comparison) const errorProneNodeTypes = [ 'httprequest', 'webhook', 'emailsend', 'slack', 'discord', 'telegram', 'postgres', 'mysql', 'mongodb', 'redis', 'github', 'gitlab', 'jira', 'salesforce', 'hubspot', 'airtable', 'googlesheets', 'googledrive', 'dropbox', 's3', 'ftp', 'ssh', 'mqtt', 'kafka', 'rabbitmq', 'graphql', 'openai', 'anthropic' ]; const normalizedType = node.type.toLowerCase(); const isErrorProne = errorProneNodeTypes.some(type => normalizedType.includes(type)); // CRITICAL: Check for node-level properties in wrong location (inside parameters) const nodeLevelProps = [ // Error handling properties 'onError', 'continueOnFail', 'retryOnFail', 'maxTries', 'waitBetweenTries', 'alwaysOutputData', // Other node-level properties 'executeOnce', 'disabled', 'notes', 'notesInFlow', 'credentials' ]; const misplacedProps: string[] = []; if (node.parameters) { for (const prop of nodeLevelProps) { if (node.parameters[prop] !== undefined) { misplacedProps.push(prop); } } } if (misplacedProps.length > 0) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Node-level properties ${misplacedProps.join(', ')} are in the wrong location. They must be at the node level, not inside parameters.`, details: { fix: `Move these properties from node.parameters to the node level. Example:\n` + `{\n` + ` "name": "${node.name}",\n` + ` "type": "${node.type}",\n` + ` "parameters": { /* operation-specific params */ },\n` + ` "onError": "continueErrorOutput", // ✅ Correct location\n` + ` "retryOnFail": true, // ✅ Correct location\n` + ` "executeOnce": true, // ✅ Correct location\n` + ` "disabled": false, // ✅ Correct location\n` + ` "credentials": { /* ... */ } // ✅ Correct location\n` + `}` } }); } // Validate error handling properties // Check for onError property (the modern approach) if (node.onError !== undefined) { const validOnErrorValues = ['continueRegularOutput', 'continueErrorOutput', 'stopWorkflow']; if (!validOnErrorValues.includes(node.onError)) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Invalid onError value: "${node.onError}". Must be one of: ${validOnErrorValues.join(', ')}` }); } } // Check for deprecated continueOnFail if (node.continueOnFail !== undefined) { if (typeof node.continueOnFail !== 'boolean') { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'continueOnFail must be a boolean value' }); } else if (node.continueOnFail === true) { // Warn about using deprecated property result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'Using deprecated "continueOnFail: true". Use "onError: \'continueRegularOutput\'" instead for better control and UI compatibility.' }); } } // Check for conflicting error handling properties if (node.continueOnFail !== undefined && node.onError !== undefined) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'Cannot use both "continueOnFail" and "onError" properties. Use only "onError" for modern workflows.' }); } if (node.retryOnFail !== undefined) { if (typeof node.retryOnFail !== 'boolean') { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'retryOnFail must be a boolean value' }); } // If retry is enabled, check retry configuration if (node.retryOnFail === true) { if (node.maxTries !== undefined) { if (typeof node.maxTries !== 'number' || node.maxTries < 1) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'maxTries must be a positive number when retryOnFail is enabled' }); } else if (node.maxTries > 10) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `maxTries is set to ${node.maxTries}. Consider if this many retries is necessary.` }); } } else { // maxTries defaults to 3 if not specified result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'retryOnFail is enabled but maxTries is not specified. Default is 3 attempts.' }); } if (node.waitBetweenTries !== undefined) { if (typeof node.waitBetweenTries !== 'number' || node.waitBetweenTries < 0) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'waitBetweenTries must be a non-negative number (milliseconds)' }); } else if (node.waitBetweenTries > 300000) { // 5 minutes result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `waitBetweenTries is set to ${node.waitBetweenTries}ms (${(node.waitBetweenTries/1000).toFixed(1)}s). This seems excessive.` }); } } } } if (node.alwaysOutputData !== undefined && typeof node.alwaysOutputData !== 'boolean') { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'alwaysOutputData must be a boolean value' }); } // Warnings for error-prone nodes without error handling const hasErrorHandling = node.onError || node.continueOnFail || node.retryOnFail; if (isErrorProne && !hasErrorHandling) { const nodeTypeSimple = normalizedType.split('.').pop() || normalizedType; // Special handling for specific node types if (normalizedType.includes('httprequest')) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'HTTP Request node without error handling. Consider adding "onError: \'continueRegularOutput\'" for non-critical requests or "retryOnFail: true" for transient failures.' }); } else if (normalizedType.includes('webhook')) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'Webhook node without error handling. Consider adding "onError: \'continueRegularOutput\'" to prevent workflow failures from blocking webhook responses.' }); } else if (errorProneNodeTypes.some(db => normalizedType.includes(db) && ['postgres', 'mysql', 'mongodb'].includes(db))) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Database operation without error handling. Consider adding "retryOnFail: true" for connection issues or "onError: \'continueRegularOutput\'" for non-critical queries.` }); } else { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `${nodeTypeSimple} node without error handling. Consider using "onError" property for better error management.` }); } } // Check for problematic combinations if (node.continueOnFail && node.retryOnFail) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'Both continueOnFail and retryOnFail are enabled. The node will retry first, then continue on failure.' }); } // Validate additional node-level properties // Check executeOnce if (node.executeOnce !== undefined && typeof node.executeOnce !== 'boolean') { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'executeOnce must be a boolean value' }); } // Check disabled if (node.disabled !== undefined && typeof node.disabled !== 'boolean') { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'disabled must be a boolean value' }); } // Check notesInFlow if (node.notesInFlow !== undefined && typeof node.notesInFlow !== 'boolean') { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'notesInFlow must be a boolean value' }); } // Check notes if (node.notes !== undefined && typeof node.notes !== 'string') { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: 'notes must be a string value' }); } // Provide guidance for executeOnce if (node.executeOnce === true) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'executeOnce is enabled. This node will execute only once regardless of input items.' }); } // Suggest alwaysOutputData for debugging if ((node.continueOnFail || node.retryOnFail) && !node.alwaysOutputData) { if (normalizedType.includes('httprequest') || normalizedType.includes('webhook')) { result.suggestions.push( `Consider enabling alwaysOutputData on "${node.name}" to capture error responses for debugging` ); } } } /** * Generate error handling suggestions based on all nodes */ private generateErrorHandlingSuggestions( workflow: WorkflowJson, result: WorkflowValidationResult ): void { // Add general suggestions based on findings const nodesWithoutErrorHandling = workflow.nodes.filter(n => !n.disabled && !n.onError && !n.continueOnFail && !n.retryOnFail ).length; if (nodesWithoutErrorHandling > 5 && workflow.nodes.length > 5) { result.suggestions.push( 'Most nodes lack error handling. Use "onError" property for modern error handling: "continueRegularOutput" (continue on error), "continueErrorOutput" (use error output), or "stopWorkflow" (stop execution).' ); } // Check for nodes using deprecated continueOnFail const nodesWithDeprecatedErrorHandling = workflow.nodes.filter(n => !n.disabled && n.continueOnFail === true ).length; if (nodesWithDeprecatedErrorHandling > 0) { result.suggestions.push( 'Replace "continueOnFail: true" with "onError: \'continueRegularOutput\'" for better UI compatibility and control.' ); } } /** * Validate SplitInBatches node connections for common mistakes */ private validateSplitInBatchesConnection( sourceNode: WorkflowNode, outputIndex: number, connection: { node: string; type: string; index: number }, nodeMap: Map<string, WorkflowNode>, result: WorkflowValidationResult ): void { const targetNode = nodeMap.get(connection.node); if (!targetNode) return; // Check if connections appear to be reversed // Output 0 = "done", Output 1 = "loop" if (outputIndex === 0) { // This is the "done" output (index 0) // Check if target looks like it should be in the loop const targetType = targetNode.type.toLowerCase(); const targetName = targetNode.name.toLowerCase(); // Common patterns that suggest this node should be inside the loop if (targetType.includes('function') || targetType.includes('code') || targetType.includes('item') || targetName.includes('process') || targetName.includes('transform') || targetName.includes('handle')) { // Check if this node connects back to the SplitInBatches const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap); if (hasLoopBack) { result.errors.push({ type: 'error', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `SplitInBatches outputs appear reversed! Node "${targetNode.name}" is connected to output 0 ("done") but connects back to the loop. It should be connected to output 1 ("loop") instead. Remember: Output 0 = "done" (post-loop), Output 1 = "loop" (inside loop).` }); } else { result.warnings.push({ type: 'warning', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `Node "${targetNode.name}" is connected to the "done" output (index 0) but appears to be a processing node. Consider connecting it to the "loop" output (index 1) if it should process items inside the loop.` }); } } } else if (outputIndex === 1) { // This is the "loop" output (index 1) // Check if target looks like it should be after the loop const targetType = targetNode.type.toLowerCase(); const targetName = targetNode.name.toLowerCase(); // Common patterns that suggest this node should be after the loop if (targetType.includes('aggregate') || targetType.includes('merge') || targetType.includes('email') || targetType.includes('slack') || targetName.includes('final') || targetName.includes('complete') || targetName.includes('summary') || targetName.includes('report')) { result.warnings.push({ type: 'warning', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `Node "${targetNode.name}" is connected to the "loop" output (index 1) but appears to be a post-processing node. Consider connecting it to the "done" output (index 0) if it should run after all iterations complete.` }); } // Check if loop output doesn't eventually connect back const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap); if (!hasLoopBack) { result.warnings.push({ type: 'warning', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `The "loop" output connects to "${targetNode.name}" but doesn't connect back to the SplitInBatches node. The last node in the loop should connect back to complete the iteration.` }); } } } /** * Check if a node eventually connects back to a target node */ private checkForLoopBack( startNode: string, targetNode: string, nodeMap: Map<string, WorkflowNode>, visited: Set<string> = new Set(), maxDepth: number = 50 ): boolean { if (maxDepth <= 0) return false; // Prevent stack overflow if (visited.has(startNode)) return false; visited.add(startNode); const node = nodeMap.get(startNode); if (!node) return false; // Access connections from the workflow structure, not the node // We need to access this.currentWorkflow.connections[startNode] const connections = (this as any).currentWorkflow?.connections[startNode]; if (!connections) return false; for (const [outputType, outputs] of Object.entries(connections)) { if (!Array.isArray(outputs)) continue; for (const outputConnections of outputs) { if (!Array.isArray(outputConnections)) continue; for (const conn of outputConnections) { if (conn.node === targetNode) { return true; } // Recursively check connected nodes if (this.checkForLoopBack(conn.node, targetNode, nodeMap, visited, maxDepth - 1)) { return true; } } } } return false; } /** * Add AI-specific error recovery suggestions */ private addErrorRecoverySuggestions(result: WorkflowValidationResult): void { // Categorize errors and provide specific recovery actions const errorTypes = { nodeType: result.errors.filter(e => e.message.includes('node type') || e.message.includes('Node type')), connection: result.errors.filter(e => e.message.includes('connection') || e.message.includes('Connection')), structure: result.errors.filter(e => e.message.includes('structure') || e.message.includes('nodes must be')), configuration: result.errors.filter(e => e.message.includes('property') || e.message.includes('field')), typeVersion: result.errors.filter(e => e.message.includes('typeVersion')) }; // Add recovery suggestions based on error types if (errorTypes.nodeType.length > 0) { result.suggestions.unshift( '🔧 RECOVERY: Invalid node types detected. Use these patterns:', ' • For core nodes: "n8n-nodes-base.nodeName" (e.g., "n8n-nodes-base.webhook")', ' • For AI nodes: "@n8n/n8n-nodes-langchain.nodeName"', ' • Never use just the node name without package prefix' ); } if (errorTypes.connection.length > 0) { result.suggestions.unshift( '🔧 RECOVERY: Connection errors detected. Fix with:', ' • Use node NAMES in connections, not IDs or types', ' • Structure: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }', ' • Ensure all referenced nodes exist in the workflow' ); } if (errorTypes.structure.length > 0) { result.suggestions.unshift( '🔧 RECOVERY: Workflow structure errors. Fix with:', ' • Ensure "nodes" is an array: "nodes": [...]', ' • Ensure "connections" is an object: "connections": {...}', ' • Add at least one node to create a valid workflow' ); } if (errorTypes.configuration.length > 0) { result.suggestions.unshift( '🔧 RECOVERY: Node configuration errors. Fix with:', ' • Check required fields using validate_node_minimal first', ' • Use get_node_essentials to see what fields are needed', ' • Ensure operation-specific fields match the node\'s requirements' ); } if (errorTypes.typeVersion.length > 0) { result.suggestions.unshift( '🔧 RECOVERY: TypeVersion errors. Fix with:', ' • Add "typeVersion": 1 (or latest version) to each node', ' • Use get_node_info to check the correct version for each node type' ); } // Add general recovery workflow if (result.errors.length > 3) { result.suggestions.push( '📋 SUGGESTED WORKFLOW: Too many errors detected. Try this approach:', ' 1. Fix structural issues first (nodes array, connections object)', ' 2. Validate node types and fix invalid ones', ' 3. Add required typeVersion to all nodes', ' 4. Test connections step by step', ' 5. Use validate_node_minimal on individual nodes to verify configuration' ); } } } ```