This is page 8 of 45. Use http://codebase.md/czlonkowski/n8n-mcp?page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /scripts/extract-from-docker.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node const dotenv = require('dotenv'); const { NodeDocumentationService } = require('../dist/services/node-documentation-service'); const { NodeSourceExtractor } = require('../dist/utils/node-source-extractor'); const { logger } = require('../dist/utils/logger'); const fs = require('fs').promises; const path = require('path'); // Load environment variables dotenv.config(); async function extractNodesFromDocker() { logger.info('🐳 Starting Docker-based node extraction...'); // Add Docker volume paths to environment for NodeSourceExtractor const dockerVolumePaths = [ process.env.N8N_MODULES_PATH || '/n8n-modules', process.env.N8N_CUSTOM_PATH || '/n8n-custom', ]; logger.info(`Docker volume paths: ${dockerVolumePaths.join(', ')}`); // Check if volumes are mounted for (const volumePath of dockerVolumePaths) { try { await fs.access(volumePath); logger.info(`✅ Volume mounted: ${volumePath}`); // List what's in the volume const entries = await fs.readdir(volumePath); logger.info(`Contents of ${volumePath}: ${entries.slice(0, 10).join(', ')}${entries.length > 10 ? '...' : ''}`); } catch (error) { logger.warn(`❌ Volume not accessible: ${volumePath}`); } } // Initialize services const docService = new NodeDocumentationService(); const extractor = new NodeSourceExtractor(); // Extend the extractor's search paths with Docker volumes extractor.n8nBasePaths.unshift(...dockerVolumePaths); // Clear existing nodes to ensure we only have latest versions logger.info('🧹 Clearing existing nodes...'); const db = docService.db; db.prepare('DELETE FROM nodes').run(); logger.info('🔍 Searching for n8n nodes in Docker volumes...'); // Known n8n packages to extract const n8nPackages = [ 'n8n-nodes-base', '@n8n/n8n-nodes-langchain', 'n8n-nodes-extras', ]; let totalExtracted = 0; let ifNodeVersion = null; for (const packageName of n8nPackages) { logger.info(`\n📦 Processing package: ${packageName}`); try { // Find package in Docker volumes let packagePath = null; for (const volumePath of dockerVolumePaths) { const possiblePaths = [ path.join(volumePath, packageName), path.join(volumePath, '.pnpm', `${packageName}@*`, 'node_modules', packageName), ]; for (const testPath of possiblePaths) { try { // Use glob pattern to find pnpm packages if (testPath.includes('*')) { const baseDir = path.dirname(testPath.split('*')[0]); const entries = await fs.readdir(baseDir); for (const entry of entries) { if (entry.includes(packageName.replace('/', '+'))) { const fullPath = path.join(baseDir, entry, 'node_modules', packageName); try { await fs.access(fullPath); packagePath = fullPath; break; } catch {} } } } else { await fs.access(testPath); packagePath = testPath; break; } } catch {} } if (packagePath) break; } if (!packagePath) { logger.warn(`Package ${packageName} not found in Docker volumes`); continue; } logger.info(`Found package at: ${packagePath}`); // Check package version try { const packageJsonPath = path.join(packagePath, 'package.json'); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); logger.info(`Package version: ${packageJson.version}`); } catch {} // Find nodes directory const nodesPath = path.join(packagePath, 'dist', 'nodes'); try { await fs.access(nodesPath); logger.info(`Scanning nodes directory: ${nodesPath}`); // Extract all nodes from this package const nodeEntries = await scanForNodes(nodesPath); logger.info(`Found ${nodeEntries.length} nodes in ${packageName}`); for (const nodeEntry of nodeEntries) { try { const nodeName = nodeEntry.name.replace('.node.js', ''); const nodeType = `${packageName}.${nodeName}`; logger.info(`Extracting: ${nodeType}`); // Extract source info const sourceInfo = await extractor.extractNodeSource(nodeType); // Check if this is the If node if (nodeName === 'If') { // Look for version in the source code const versionMatch = sourceInfo.sourceCode.match(/version:\s*(\d+)/); if (versionMatch) { ifNodeVersion = versionMatch[1]; logger.info(`📍 Found If node version: ${ifNodeVersion}`); } } // Store in database await docService.storeNode({ nodeType: nodeType, name: nodeName, displayName: nodeName, description: `${nodeName} node from ${packageName}`, sourceCode: sourceInfo.sourceCode, credentialCode: sourceInfo.credentialCode, packageName: packageName, version: ifNodeVersion || '1', hasCredentials: !!sourceInfo.credentialCode, isTrigger: sourceInfo.sourceCode.includes('trigger: true') || nodeName.toLowerCase().includes('trigger'), isWebhook: sourceInfo.sourceCode.includes('webhook: true') || nodeName.toLowerCase().includes('webhook'), }); totalExtracted++; } catch (error) { logger.error(`Failed to extract ${nodeEntry.name}: ${error}`); } } } catch (error) { logger.error(`Failed to scan nodes directory: ${error}`); } } catch (error) { logger.error(`Failed to process package ${packageName}: ${error}`); } } logger.info(`\n✅ Extraction complete!`); logger.info(`📊 Total nodes extracted: ${totalExtracted}`); if (ifNodeVersion) { logger.info(`📍 If node version: ${ifNodeVersion}`); if (ifNodeVersion === '2' || ifNodeVersion === '2.2') { logger.info('✅ Successfully extracted latest If node (v2+)!'); } else { logger.warn(`⚠️ If node version is ${ifNodeVersion}, expected v2 or higher`); } } // Close database docService.close(); } async function scanForNodes(dirPath) { const nodes = []; async function scan(currentPath) { try { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); if (entry.isFile() && entry.name.endsWith('.node.js')) { nodes.push({ name: entry.name, path: fullPath }); } else if (entry.isDirectory() && entry.name !== 'node_modules') { await scan(fullPath); } } } catch (error) { logger.debug(`Failed to scan directory ${currentPath}: ${error}`); } } await scan(dirPath); return nodes; } // Run extraction extractNodesFromDocker().catch(error => { logger.error('Extraction failed:', error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /docs/MCP_ESSENTIALS_README.md: -------------------------------------------------------------------------------- ```markdown # n8n MCP Essentials Tools - User Guide ## Overview The n8n MCP has been enhanced with new tools that dramatically improve the AI agent experience when building n8n workflows. The key improvement is the `get_node_essentials` tool which reduces response sizes by 95% while providing all the information needed for basic configuration. ## New Tools ### 1. `get_node_essentials` **Purpose**: Get only the 10-20 most important properties for a node instead of 200+ **When to use**: - Starting to configure a new node - Need quick access to common properties - Want working examples - Building basic workflows **Example usage**: ```json { "name": "get_node_essentials", "arguments": { "nodeType": "nodes-base.httpRequest" } } ``` **Response structure**: ```json { "nodeType": "nodes-base.httpRequest", "displayName": "HTTP Request", "description": "Makes HTTP requests and returns the response data", "requiredProperties": [ { "name": "url", "displayName": "URL", "type": "string", "description": "The URL to make the request to", "placeholder": "https://api.example.com/endpoint" } ], "commonProperties": [ { "name": "method", "type": "options", "options": [ { "value": "GET", "label": "GET" }, { "value": "POST", "label": "POST" } ], "default": "GET" } // ... 4-5 more common properties ], "examples": { "minimal": { "url": "https://api.example.com/data" }, "common": { "method": "POST", "url": "https://api.example.com/users", "sendBody": true, "contentType": "json", "jsonBody": "{ \"name\": \"John\" }" } }, "metadata": { "totalProperties": 245, "isAITool": false, "isTrigger": false } } ``` **Benefits**: - 95% smaller response (5KB vs 100KB+) - Only shows properties you actually need - Includes working examples - No duplicate or confusing properties - Clear indication of what's required ### 2. `search_node_properties` **Purpose**: Find specific properties within a node without downloading everything **When to use**: - Looking for authentication options - Finding specific configuration like headers or body - Exploring what options are available - Need to configure advanced features **Example usage**: ```json { "name": "search_node_properties", "arguments": { "nodeType": "nodes-base.httpRequest", "query": "auth" } } ``` **Response structure**: ```json { "nodeType": "nodes-base.httpRequest", "query": "auth", "matches": [ { "name": "authentication", "displayName": "Authentication", "type": "options", "description": "Method of authentication to use", "path": "authentication", "options": [ { "value": "none", "label": "None" }, { "value": "basicAuth", "label": "Basic Auth" } ] }, { "name": "genericAuthType", "path": "genericAuthType", "showWhen": { "authentication": "genericCredentialType" } } ], "totalMatches": 5, "searchedIn": "245 properties" } ``` ## Recommended Workflow ### For Basic Configuration: 1. **Start with essentials**: ``` get_node_essentials("nodes-base.httpRequest") ``` 2. **Use the provided examples**: - Start with `minimal` example - Upgrade to `common` for typical use cases - Modify based on your needs 3. **Search for specific features** (if needed): ``` search_node_properties("nodes-base.httpRequest", "header") ``` ### For Complex Configuration: 1. **Get documentation first**: ``` get_node_documentation("nodes-base.httpRequest") ``` 2. **Get essentials for the basics**: ``` get_node_essentials("nodes-base.httpRequest") ``` 3. **Search for advanced properties**: ``` search_node_properties("nodes-base.httpRequest", "proxy") ``` 4. **Only use get_node_info if absolutely necessary**: ``` get_node_info("nodes-base.httpRequest") // Last resort - 100KB+ response ``` ## Common Patterns ### Making API Calls: ```javascript // Start with essentials const essentials = get_node_essentials("nodes-base.httpRequest"); // Use the POST example const config = essentials.examples.common; // Modify for your needs config.url = "https://api.myservice.com/endpoint"; config.jsonBody = JSON.stringify({ my: "data" }); ``` ### Setting up Webhooks: ```javascript // Get webhook essentials const essentials = get_node_essentials("nodes-base.webhook"); // Start with minimal const config = essentials.examples.minimal; config.path = "my-webhook-endpoint"; ``` ### Database Operations: ```javascript // Get database essentials const essentials = get_node_essentials("nodes-base.postgres"); // Check available operations const operations = essentials.operations; // Use appropriate example const config = essentials.examples.common; ``` ## Tips for AI Agents 1. **Always start with get_node_essentials** - It has everything needed for 90% of use cases 2. **Use examples as templates** - They're tested, working configurations 3. **Search before diving deep** - Use search_node_properties to find specific options 4. **Check metadata** - Know if you need credentials, if it's a trigger, etc. 5. **Progressive disclosure** - Start simple, add complexity only when needed ## Supported Nodes The essentials tool has optimized configurations for 20+ commonly used nodes: - **Core**: httpRequest, webhook, code, set, if, merge, splitInBatches - **Databases**: postgres, mysql, mongodb, redis - **Communication**: slack, email, discord - **Files**: ftp, ssh, googleSheets - **AI**: openAi, agent - **Utilities**: executeCommand, function For other nodes, the tool automatically extracts the most important properties. ## Performance Metrics Based on testing with top 10 nodes: - **Average size reduction**: 94.3% - **Response time improvement**: 78% - **Properties shown**: 10-20 (vs 200+) - **Usability improvement**: Dramatic ## Migration Guide If you're currently using `get_node_info`, here's how to migrate: ### Before: ```javascript const node = get_node_info("nodes-base.httpRequest"); // Parse through 200+ properties // Figure out what's required // Deal with duplicates and conditionals ``` ### After: ```javascript const essentials = get_node_essentials("nodes-base.httpRequest"); // Use essentials.requiredProperties // Use essentials.commonProperties // Start with essentials.examples.common ``` ## Troubleshooting **Q: The tool says node not found** A: Use the full node type with prefix: `nodes-base.httpRequest` not just `httpRequest` **Q: I need a property that's not in essentials** A: Use `search_node_properties` to find it, or `get_node_info` as last resort **Q: The examples don't cover my use case** A: Start with the closest example and modify. Use search to find additional properties. **Q: How do I know what properties are available?** A: Check `metadata.totalProperties` to see how many are available, then search for what you need ## Future Improvements Planned enhancements: - Task-based configurations (e.g., "post_json_with_auth") - Configuration validation - Property dependency resolution - More node coverage ## Summary The new essentials tools make n8n workflow building with AI agents actually practical. Instead of overwhelming agents with hundreds of properties, we provide just what's needed, when it's needed. This results in faster, more accurate workflow creation with fewer errors. ``` -------------------------------------------------------------------------------- /src/scripts/extract-from-docker.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import * as dotenv from 'dotenv'; import { NodeDocumentationService } from '../services/node-documentation-service'; import { NodeSourceExtractor } from '../utils/node-source-extractor'; import { logger } from '../utils/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; // Load environment variables dotenv.config(); async function extractNodesFromDocker() { logger.info('🐳 Starting Docker-based node extraction...'); // Add Docker volume paths to environment for NodeSourceExtractor const dockerVolumePaths = [ process.env.N8N_MODULES_PATH || '/n8n-modules', process.env.N8N_CUSTOM_PATH || '/n8n-custom', ]; logger.info(`Docker volume paths: ${dockerVolumePaths.join(', ')}`); // Check if volumes are mounted for (const volumePath of dockerVolumePaths) { try { await fs.access(volumePath); logger.info(`✅ Volume mounted: ${volumePath}`); // List what's in the volume const entries = await fs.readdir(volumePath); logger.info(`Contents of ${volumePath}: ${entries.slice(0, 10).join(', ')}${entries.length > 10 ? '...' : ''}`); } catch (error) { logger.warn(`❌ Volume not accessible: ${volumePath}`); } } // Initialize services const docService = new NodeDocumentationService(); const extractor = new NodeSourceExtractor(); // Extend the extractor's search paths with Docker volumes (extractor as any).n8nBasePaths.unshift(...dockerVolumePaths); // Clear existing nodes to ensure we only have latest versions logger.info('🧹 Clearing existing nodes...'); const db = (docService as any).db; db.prepare('DELETE FROM nodes').run(); logger.info('🔍 Searching for n8n nodes in Docker volumes...'); // Known n8n packages to extract const n8nPackages = [ 'n8n-nodes-base', '@n8n/n8n-nodes-langchain', 'n8n-nodes-extras', ]; let totalExtracted = 0; let ifNodeVersion = null; for (const packageName of n8nPackages) { logger.info(`\n📦 Processing package: ${packageName}`); try { // Find package in Docker volumes let packagePath = null; for (const volumePath of dockerVolumePaths) { const possiblePaths = [ path.join(volumePath, packageName), path.join(volumePath, '.pnpm', `${packageName}@*`, 'node_modules', packageName), ]; for (const testPath of possiblePaths) { try { // Use glob pattern to find pnpm packages if (testPath.includes('*')) { const baseDir = path.dirname(testPath.split('*')[0]); const entries = await fs.readdir(baseDir); for (const entry of entries) { if (entry.includes(packageName.replace('/', '+'))) { const fullPath = path.join(baseDir, entry, 'node_modules', packageName); try { await fs.access(fullPath); packagePath = fullPath; break; } catch {} } } } else { await fs.access(testPath); packagePath = testPath; break; } } catch {} } if (packagePath) break; } if (!packagePath) { logger.warn(`Package ${packageName} not found in Docker volumes`); continue; } logger.info(`Found package at: ${packagePath}`); // Check package version try { const packageJsonPath = path.join(packagePath, 'package.json'); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); logger.info(`Package version: ${packageJson.version}`); } catch {} // Find nodes directory const nodesPath = path.join(packagePath, 'dist', 'nodes'); try { await fs.access(nodesPath); logger.info(`Scanning nodes directory: ${nodesPath}`); // Extract all nodes from this package const nodeEntries = await scanForNodes(nodesPath); logger.info(`Found ${nodeEntries.length} nodes in ${packageName}`); for (const nodeEntry of nodeEntries) { try { const nodeName = nodeEntry.name.replace('.node.js', ''); const nodeType = `${packageName}.${nodeName}`; logger.info(`Extracting: ${nodeType}`); // Extract source info const sourceInfo = await extractor.extractNodeSource(nodeType); // Check if this is the If node if (nodeName === 'If') { // Look for version in the source code const versionMatch = sourceInfo.sourceCode.match(/version:\s*(\d+)/); if (versionMatch) { ifNodeVersion = versionMatch[1]; logger.info(`📍 Found If node version: ${ifNodeVersion}`); } } // Store in database await docService.storeNode({ nodeType: nodeType, name: nodeName, displayName: nodeName, description: `${nodeName} node from ${packageName}`, sourceCode: sourceInfo.sourceCode, credentialCode: sourceInfo.credentialCode, packageName: packageName, version: ifNodeVersion || '1', hasCredentials: !!sourceInfo.credentialCode, isTrigger: sourceInfo.sourceCode.includes('trigger: true') || nodeName.toLowerCase().includes('trigger'), isWebhook: sourceInfo.sourceCode.includes('webhook: true') || nodeName.toLowerCase().includes('webhook'), }); totalExtracted++; } catch (error) { logger.error(`Failed to extract ${nodeEntry.name}: ${error}`); } } } catch (error) { logger.error(`Failed to scan nodes directory: ${error}`); } } catch (error) { logger.error(`Failed to process package ${packageName}: ${error}`); } } logger.info(`\n✅ Extraction complete!`); logger.info(`📊 Total nodes extracted: ${totalExtracted}`); if (ifNodeVersion) { logger.info(`📍 If node version: ${ifNodeVersion}`); if (ifNodeVersion === '2' || ifNodeVersion === '2.2') { logger.info('✅ Successfully extracted latest If node (v2+)!'); } else { logger.warn(`⚠️ If node version is ${ifNodeVersion}, expected v2 or higher`); } } // Close database await docService.close(); } async function scanForNodes(dirPath: string): Promise<{ name: string; path: string }[]> { const nodes: { name: string; path: string }[] = []; async function scan(currentPath: string) { try { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); if (entry.isFile() && entry.name.endsWith('.node.js')) { nodes.push({ name: entry.name, path: fullPath }); } else if (entry.isDirectory() && entry.name !== 'node_modules') { await scan(fullPath); } } } catch (error) { logger.debug(`Failed to scan directory ${currentPath}: ${error}`); } } await scan(dirPath); return nodes; } // Run extraction extractNodesFromDocker().catch(error => { logger.error('Extraction failed:', error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/scripts/test-autofix-workflow.ts: -------------------------------------------------------------------------------- ```typescript /** * Test script for n8n_autofix_workflow functionality * * Tests the automatic fixing of common workflow validation errors: * 1. Expression format errors (missing = prefix) * 2. TypeVersion corrections * 3. Error output configuration issues */ import { WorkflowAutoFixer } from '../services/workflow-auto-fixer'; import { WorkflowValidator } from '../services/workflow-validator'; import { EnhancedConfigValidator } from '../services/enhanced-config-validator'; import { ExpressionFormatValidator } from '../services/expression-format-validator'; import { NodeRepository } from '../database/node-repository'; import { Logger } from '../utils/logger'; import { createDatabaseAdapter } from '../database/database-adapter'; import * as path from 'path'; const logger = new Logger({ prefix: '[TestAutofix]' }); async function testAutofix() { // Initialize database and repository const dbPath = path.join(__dirname, '../../data/nodes.db'); const dbAdapter = await createDatabaseAdapter(dbPath); const repository = new NodeRepository(dbAdapter); // Test workflow with various issues const testWorkflow = { id: 'test_workflow_1', name: 'Test Workflow for Autofix', nodes: [ { id: 'webhook_1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 1.1, position: [250, 300], parameters: { httpMethod: 'GET', path: 'test-webhook', responseMode: 'onReceived', responseData: 'firstEntryJson' } }, { id: 'http_1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 5.0, // Invalid - max is 4.2 position: [450, 300], parameters: { method: 'GET', url: '{{ $json.webhookUrl }}', // Missing = prefix sendHeaders: true, headerParameters: { parameters: [ { name: 'Authorization', value: '{{ $json.token }}' // Missing = prefix } ] } }, onError: 'continueErrorOutput' // Has onError but no error connections }, { id: 'set_1', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3.5, // Invalid version position: [650, 300], parameters: { mode: 'manual', duplicateItem: false, values: { values: [ { name: 'status', value: '{{ $json.success }}' // Missing = prefix } ] } } } ], connections: { 'Webhook': { main: [ [ { node: 'HTTP Request', type: 'main', index: 0 } ] ] }, 'HTTP Request': { main: [ [ { node: 'Set', type: 'main', index: 0 } ] // Missing error output connection for onError: 'continueErrorOutput' ] } } }; logger.info('=== Testing Workflow Auto-Fixer ===\n'); // Step 1: Validate the workflow to identify issues logger.info('Step 1: Validating workflow to identify issues...'); const validator = new WorkflowValidator(repository, EnhancedConfigValidator); const validationResult = await validator.validateWorkflow(testWorkflow as any, { validateNodes: true, validateConnections: true, validateExpressions: true, profile: 'ai-friendly' }); logger.info(`Found ${validationResult.errors.length} errors and ${validationResult.warnings.length} warnings`); // Step 2: Check for expression format issues logger.info('\nStep 2: Checking for expression format issues...'); const allFormatIssues: any[] = []; for (const node of testWorkflow.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); } logger.info(`Found ${allFormatIssues.length} expression format issues`); // Debug: Show the actual format issues if (allFormatIssues.length > 0) { logger.info('\nExpression format issues found:'); for (const issue of allFormatIssues) { logger.info(` - ${issue.fieldPath}: ${issue.issueType} (${issue.severity})`); logger.info(` Current: ${JSON.stringify(issue.currentValue)}`); logger.info(` Fixed: ${JSON.stringify(issue.correctedValue)}`); } } // Step 3: Generate fixes in preview mode logger.info('\nStep 3: Generating fixes (preview mode)...'); const autoFixer = new WorkflowAutoFixer(); const previewResult = autoFixer.generateFixes( testWorkflow as any, validationResult, allFormatIssues, { applyFixes: false, // Preview mode confidenceThreshold: 'medium' } ); logger.info(`\nGenerated ${previewResult.fixes.length} fixes:`); logger.info(`Summary: ${previewResult.summary}`); logger.info('\nFixes by type:'); for (const [type, count] of Object.entries(previewResult.stats.byType)) { if (count > 0) { logger.info(` - ${type}: ${count}`); } } logger.info('\nFixes by confidence:'); for (const [confidence, count] of Object.entries(previewResult.stats.byConfidence)) { if (count > 0) { logger.info(` - ${confidence}: ${count}`); } } // Step 4: Display individual fixes logger.info('\nDetailed fixes:'); for (const fix of previewResult.fixes) { logger.info(`\n[${fix.confidence.toUpperCase()}] ${fix.node}.${fix.field} (${fix.type})`); logger.info(` Before: ${JSON.stringify(fix.before)}`); logger.info(` After: ${JSON.stringify(fix.after)}`); logger.info(` Description: ${fix.description}`); } // Step 5: Display generated operations logger.info('\n\nGenerated diff operations:'); for (const op of previewResult.operations) { logger.info(`\nOperation: ${op.type}`); logger.info(` Details: ${JSON.stringify(op, null, 2)}`); } // Step 6: Test with different confidence thresholds logger.info('\n\n=== Testing Different Confidence Thresholds ==='); for (const threshold of ['high', 'medium', 'low'] as const) { const result = autoFixer.generateFixes( testWorkflow as any, validationResult, allFormatIssues, { applyFixes: false, confidenceThreshold: threshold } ); logger.info(`\nThreshold "${threshold}": ${result.fixes.length} fixes`); } // Step 7: Test with specific fix types logger.info('\n\n=== Testing Specific Fix Types ==='); const fixTypes = ['expression-format', 'typeversion-correction', 'error-output-config'] as const; for (const fixType of fixTypes) { const result = autoFixer.generateFixes( testWorkflow as any, validationResult, allFormatIssues, { applyFixes: false, fixTypes: [fixType] } ); logger.info(`\nFix type "${fixType}": ${result.fixes.length} fixes`); } logger.info('\n\n✅ Autofix test completed successfully!'); await dbAdapter.close(); } // Run the test testAutofix().catch(error => { logger.error('Test failed:', error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/parsers/property-extractor.ts: -------------------------------------------------------------------------------- ```typescript import type { NodeClass } from '../types/node-types'; export class PropertyExtractor { /** * Extract properties with proper handling of n8n's complex structures */ extractProperties(nodeClass: NodeClass): any[] { const properties: any[] = []; // First try to get instance-level properties let instance: any; try { instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass; } catch (e) { // Failed to instantiate } // Handle versioned nodes - check instance for nodeVersions if (instance?.nodeVersions) { const versions = Object.keys(instance.nodeVersions).map(Number); if (versions.length > 0) { const latestVersion = Math.max(...versions); if (!isNaN(latestVersion)) { const versionedNode = instance.nodeVersions[latestVersion]; if (versionedNode?.description?.properties) { return this.normalizeProperties(versionedNode.description.properties); } } } } // Check for description with properties const description = instance?.description || instance?.baseDescription || this.getNodeDescription(nodeClass); if (description?.properties) { return this.normalizeProperties(description.properties); } return properties; } private getNodeDescription(nodeClass: NodeClass): any { // Try to get description from the class first let description: any; if (typeof nodeClass === 'function') { // Try to instantiate to get description try { const instance = new nodeClass(); // Strategic any assertion for instance properties const inst = instance as any; description = inst.description || inst.baseDescription || {}; } catch (e) { // Some nodes might require parameters to instantiate // Strategic any assertion for class-level properties const nodeClassAny = nodeClass as any; description = nodeClassAny.description || {}; } } else { // Strategic any assertion for instance properties const inst = nodeClass as any; description = inst.description || {}; } return description; } /** * Extract operations from both declarative and programmatic nodes */ extractOperations(nodeClass: NodeClass): any[] { const operations: any[] = []; // First try to get instance-level data let instance: any; try { instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass; } catch (e) { // Failed to instantiate } // Handle versioned nodes if (instance?.nodeVersions) { const versions = Object.keys(instance.nodeVersions).map(Number); if (versions.length > 0) { const latestVersion = Math.max(...versions); if (!isNaN(latestVersion)) { const versionedNode = instance.nodeVersions[latestVersion]; if (versionedNode?.description) { return this.extractOperationsFromDescription(versionedNode.description); } } } } // Get description const description = instance?.description || instance?.baseDescription || this.getNodeDescription(nodeClass); return this.extractOperationsFromDescription(description); } private extractOperationsFromDescription(description: any): any[] { const operations: any[] = []; if (!description) return operations; // Declarative nodes (with routing) if (description.routing) { const routing = description.routing; // Extract from request.resource and request.operation if (routing.request?.resource) { const resources = routing.request.resource.options || []; const operationOptions = routing.request.operation?.options || {}; resources.forEach((resource: any) => { const resourceOps = operationOptions[resource.value] || []; resourceOps.forEach((op: any) => { operations.push({ resource: resource.value, operation: op.value, name: `${resource.name} - ${op.name}`, action: op.action }); }); }); } } // Programmatic nodes - look for operation property in properties if (description.properties && Array.isArray(description.properties)) { const operationProp = description.properties.find( (p: any) => p.name === 'operation' || p.name === 'action' ); if (operationProp?.options) { operationProp.options.forEach((op: any) => { operations.push({ operation: op.value, name: op.name, description: op.description }); }); } } return operations; } /** * Deep search for AI tool capability */ detectAIToolCapability(nodeClass: NodeClass): boolean { const description = this.getNodeDescription(nodeClass); // Direct property check if (description?.usableAsTool === true) return true; // Check in actions for declarative nodes if (description?.actions?.some((a: any) => a.usableAsTool === true)) return true; // Check versioned nodes // Strategic any assertion for nodeVersions property const nodeClassAny = nodeClass as any; if (nodeClassAny.nodeVersions) { for (const version of Object.values(nodeClassAny.nodeVersions)) { if ((version as any).description?.usableAsTool === true) return true; } } // Check for specific AI-related properties const aiIndicators = ['openai', 'anthropic', 'huggingface', 'cohere', 'ai']; const nodeName = description?.name?.toLowerCase() || ''; return aiIndicators.some(indicator => nodeName.includes(indicator)); } /** * Extract credential requirements with proper structure */ extractCredentials(nodeClass: NodeClass): any[] { const credentials: any[] = []; // First try to get instance-level data let instance: any; try { instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass; } catch (e) { // Failed to instantiate } // Handle versioned nodes if (instance?.nodeVersions) { const versions = Object.keys(instance.nodeVersions).map(Number); if (versions.length > 0) { const latestVersion = Math.max(...versions); if (!isNaN(latestVersion)) { const versionedNode = instance.nodeVersions[latestVersion]; if (versionedNode?.description?.credentials) { return versionedNode.description.credentials; } } } } // Check for description with credentials const description = instance?.description || instance?.baseDescription || this.getNodeDescription(nodeClass); if (description?.credentials) { return description.credentials; } return credentials; } private normalizeProperties(properties: any[]): any[] { // Ensure all properties have consistent structure return properties.map(prop => ({ displayName: prop.displayName, name: prop.name, type: prop.type, default: prop.default, description: prop.description, options: prop.options, required: prop.required, displayOptions: prop.displayOptions, typeOptions: prop.typeOptions, modes: prop.modes, // For resourceLocator type properties - modes are at top level noDataExpression: prop.noDataExpression })); } } ``` -------------------------------------------------------------------------------- /docs/local/DEEP_DIVE_ANALYSIS_README.md: -------------------------------------------------------------------------------- ```markdown # N8N-MCP Deep Dive Analysis - October 2, 2025 ## Overview This directory contains a comprehensive deep-dive analysis of n8n-mcp usage data from September 26 - October 2, 2025. **Data Volume Analyzed:** - 212,375 telemetry events - 5,751 workflow creations - 2,119 unique users - 6 days of usage data ## Report Structure ###: `DEEP_DIVE_ANALYSIS_2025-10-02.md` (Main Report) **Sections Covered:** 1. **Executive Summary** - Key findings and recommendations 2. **Tool Performance Analysis** - Success rates, performance metrics, critical findings 3. **Validation Catastrophe** - The node type prefix disaster analysis 4. **Usage Patterns & User Segmentation** - User distribution, daily trends 5. **Tool Sequence Analysis** - How AI agents use tools together 6. **Workflow Creation Patterns** - Complexity distribution, popular nodes 7. **Platform & Version Distribution** - OS, architecture, version adoption 8. **Error Patterns & Root Causes** - TypeErrors, validation errors, discovery failures 9. **P0-P1 Refactoring Recommendations** - Detailed implementation guides **Sections Covered:** - Remaining P1 and P2 recommendations - Architectural refactoring suggestions - Telemetry enhancements - CHANGELOG integration - Final recommendations summary ## Key Findings Summary ### Critical Issues (P0 - Fix Immediately) 1. **Node Type Prefix Validation Catastrophe** - 5,000+ validation errors from single root cause - `nodes-base.X` vs `n8n-nodes-base.X` confusion - **Solution**: Auto-normalize prefixes (2-4 hours effort) 2. **TypeError in Node Information Tools** - 10-18% failure rate in get_node_essentials/info - 1,000+ failures affecting hundreds of users - **Solution**: Complete null-safety audit (1 day effort) 3. **Task Discovery Failures** - `get_node_for_task` failing 28% of the time - Worst-performing tool in entire system - **Solution**: Expand task library + fuzzy matching (3 days effort) ### Performance Metrics **Excellent Reliability (96-100% success):** - n8n_update_partial_workflow: 98.7% - search_nodes: 99.8% - n8n_create_workflow: 96.1% - All workflow management tools: 100% **User Distribution:** - Power Users (12): 2,112 events/user, 33 workflows - Heavy Users (47): 673 events/user, 18 workflows - Regular Users (516): 199 events/user, 7 workflows (CORE AUDIENCE) - Active Users (919): 52 events/user, 2 workflows - Casual Users (625): 8 events/user, 1 workflow ### Usage Insights **Most Used Tools:** 1. n8n_update_partial_workflow: 10,177 calls (iterative refinement) 2. search_nodes: 8,839 calls (node discovery) 3. n8n_create_workflow: 6,046 calls (workflow creation) **Most Common Tool Sequences:** 1. update → update → update (549x) - Iterative refinement pattern 2. create → update (297x) - Create then refine 3. update → get_workflow (265x) - Update then verify **Most Popular Nodes:** 1. code (53% of workflows) - AI agents love programmatic control 2. httpRequest (47%) - Integration-heavy usage 3. webhook (32%) - Event-driven automation ## SQL Analytical Views Created 15 comprehensive views were created in Supabase for ongoing analysis: 1. `vw_tool_performance` - Performance metrics per tool 2. `vw_error_analysis` - Error patterns and frequencies 3. `vw_validation_analysis` - Validation failure details 4. `vw_tool_sequences` - Tool-to-tool transition patterns 5. `vw_workflow_creation_patterns` - Workflow characteristics 6. `vw_node_usage_analysis` - Node popularity and complexity 7. `vw_node_cooccurrence` - Which nodes are used together 8. `vw_user_activity` - Per-user activity metrics 9. `vw_session_analysis` - Platform/version distribution 10. `vw_workflow_validation_failures` - Workflow validation issues 11. `vw_temporal_patterns` - Time-based usage patterns 12. `vw_tool_funnel` - User progression through tools 13. `vw_search_analysis` - Search behavior 14. `vw_tool_success_summary` - Success/failure rates 15. `vw_user_journeys` - Complete user session reconstruction ## Priority Recommendations ### Immediate Actions (This Week) ✅ **P0-R1**: Auto-normalize node type prefixes → Eliminate 4,800 errors ✅ **P0-R2**: Complete null-safety audit → Fix 10-18% TypeError failures ✅ **P0-R3**: Expand get_node_for_task library → 72% → 95% success rate **Expected Impact**: Reduce error rate from 5-10% to <2% overall ### Next Release (2-3 Weeks) ✅ **P1-R4**: Batch workflow operations → Save 30-50% tokens ✅ **P1-R5**: Proactive node suggestions → Reduce search iterations ✅ **P1-R6**: Auto-fix suggestions in errors → Self-service recovery **Expected Impact**: 40% faster workflow creation, better UX ### Future Roadmap (1-3 Months) ✅ **A1**: Service layer consolidation → Cleaner architecture ✅ **A2**: Repository caching → 50% faster node operations ✅ **R10**: Workflow template library from usage → 80% coverage ✅ **T1-T3**: Enhanced telemetry → Better observability **Expected Impact**: Scalable foundation for 10x growth ## Methodology ### Data Sources 1. **Supabase Telemetry Database** - `telemetry_events` table: 212,375 rows - `telemetry_workflows` table: 5,751 rows 2. **Analytical Views** - Created 15 SQL views for multi-dimensional analysis - Enabled complex queries and pattern recognition 3. **CHANGELOG Review** - Analyzed recent changes (v2.14.0 - v2.14.6) - Correlated fixes with error patterns ### Analysis Approach 1. **Quantitative Analysis** - Success/failure rates per tool - Performance metrics (avg, median, p95, p99) - User segmentation and cohort analysis - Temporal trends and growth patterns 2. **Pattern Recognition** - Tool sequence analysis (Markov chains) - Node co-occurrence patterns - Workflow complexity distribution - Error clustering and root cause analysis 3. **Qualitative Insights** - CHANGELOG integration - Error message analysis - User journey reconstruction - Best practice identification ## How to Use This Analysis ### For Development Priorities 1. Review **P0 Critical Recommendations** (Section 8) 2. Check estimated effort and impact 3. Prioritize based on ROI (impact/effort ratio) 4. Follow implementation guides with code examples ### For Architecture Decisions 1. Review **Architectural Recommendations** (Section 9) 2. Consider service layer consolidation 3. Evaluate repository caching opportunities 4. Plan for 10x scale ### For Product Strategy 1. Review **Usage Patterns** (Section 3 & 5) 2. Understand user segments (power vs casual) 3. Identify high-value features (most-used tools) 4. Focus on reliability over features (96% success rate target) ### For Telemetry Enhancement 1. Review **Telemetry Enhancements** (Section 10) 2. Add fine-grained timing metrics 3. Track workflow creation funnels 4. Monitor node-level analytics ## Contact & Feedback For questions about this analysis or to request additional insights: - Data Analyst: Claude Code with Supabase MCP - Analysis Date: October 2, 2025 - Data Period: September 26 - October 2, 2025 ## Change Log - **2025-10-02**: Initial comprehensive analysis completed - 15 SQL analytical views created - 13 sections of detailed findings - P0/P1/P2 recommendations with implementation guides - Code examples and effort estimates provided ## Next Steps 1. ✅ Review findings with development team 2. ✅ Prioritize P0 recommendations for immediate implementation 3. ✅ Plan P1 features for next release cycle 4. ✅ Set up monitoring for key metrics 5. ✅ Schedule follow-up analysis (weekly recommended) --- *This analysis represents a snapshot of n8n-mcp usage during early adoption phase. Patterns may evolve as the user base grows and matures.* ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/utils/webhook-workflows.ts: -------------------------------------------------------------------------------- ```typescript /** * Webhook Workflow Configuration * * Provides configuration and setup instructions for webhook workflows * required for integration testing. * * These workflows must be created manually in n8n and activated because * the n8n API doesn't support workflow activation. */ import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api'; export interface WebhookWorkflowConfig { name: string; description: string; httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: string; nodes: Array<Partial<WorkflowNode>>; connections: Record<string, any>; } /** * Configuration for required webhook workflows */ export const WEBHOOK_WORKFLOW_CONFIGS: Record<string, WebhookWorkflowConfig> = { GET: { name: '[MCP-TEST] Webhook GET', description: 'Pre-activated webhook for GET method testing', httpMethod: 'GET', path: 'mcp-test-get', nodes: [ { name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300], parameters: { httpMethod: 'GET', path: 'mcp-test-get', responseMode: 'lastNode', options: {} } }, { name: 'Respond to Webhook', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.1, position: [450, 300], parameters: { options: {} } } ], connections: { Webhook: { main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]] } } }, POST: { name: '[MCP-TEST] Webhook POST', description: 'Pre-activated webhook for POST method testing', httpMethod: 'POST', path: 'mcp-test-post', nodes: [ { name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300], parameters: { httpMethod: 'POST', path: 'mcp-test-post', responseMode: 'lastNode', options: {} } }, { name: 'Respond to Webhook', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.1, position: [450, 300], parameters: { options: {} } } ], connections: { Webhook: { main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]] } } }, PUT: { name: '[MCP-TEST] Webhook PUT', description: 'Pre-activated webhook for PUT method testing', httpMethod: 'PUT', path: 'mcp-test-put', nodes: [ { name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300], parameters: { httpMethod: 'PUT', path: 'mcp-test-put', responseMode: 'lastNode', options: {} } }, { name: 'Respond to Webhook', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.1, position: [450, 300], parameters: { options: {} } } ], connections: { Webhook: { main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]] } } }, DELETE: { name: '[MCP-TEST] Webhook DELETE', description: 'Pre-activated webhook for DELETE method testing', httpMethod: 'DELETE', path: 'mcp-test-delete', nodes: [ { name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300], parameters: { httpMethod: 'DELETE', path: 'mcp-test-delete', responseMode: 'lastNode', options: {} } }, { name: 'Respond to Webhook', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.1, position: [450, 300], parameters: { options: {} } } ], connections: { Webhook: { main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]] } } } }; /** * Print setup instructions for webhook workflows */ export function printSetupInstructions(): void { console.log(` ╔════════════════════════════════════════════════════════════════╗ ║ WEBHOOK WORKFLOW SETUP REQUIRED ║ ╠════════════════════════════════════════════════════════════════╣ ║ ║ ║ Integration tests require 4 pre-activated webhook workflows: ║ ║ ║ ║ 1. Create workflows manually in n8n UI ║ ║ 2. Use the configurations shown below ║ ║ 3. ACTIVATE each workflow in n8n UI ║ ║ 4. Copy workflow IDs to .env file ║ ║ ║ ╚════════════════════════════════════════════════════════════════╝ Required workflows: `); Object.entries(WEBHOOK_WORKFLOW_CONFIGS).forEach(([method, config]) => { console.log(` ${method} Method: Name: ${config.name} Path: ${config.path} .env variable: N8N_TEST_WEBHOOK_${method}_ID Workflow Structure: 1. Webhook node (${method} method, path: ${config.path}) 2. Respond to Webhook node After creating: 1. Save the workflow 2. ACTIVATE the workflow (toggle in UI) 3. Copy the workflow ID 4. Add to .env: N8N_TEST_WEBHOOK_${method}_ID=<workflow-id> `); }); console.log(` See docs/local/integration-testing-plan.md for detailed instructions. `); } /** * Generate workflow JSON for a webhook workflow * * @param method - HTTP method * @returns Partial workflow ready to create */ export function generateWebhookWorkflowJson( method: 'GET' | 'POST' | 'PUT' | 'DELETE' ): Partial<Workflow> { const config = WEBHOOK_WORKFLOW_CONFIGS[method]; return { name: config.name, nodes: config.nodes as any, connections: config.connections, active: false, // Will need to be activated manually settings: { executionOrder: 'v1' }, tags: ['mcp-integration-test', 'webhook-test'] }; } /** * Export all webhook workflow JSONs * * Returns an object with all 4 webhook workflow configurations * ready to be created in n8n. * * @returns Object with workflow configurations */ export function exportAllWebhookWorkflows(): Record<string, Partial<Workflow>> { return { GET: generateWebhookWorkflowJson('GET'), POST: generateWebhookWorkflowJson('POST'), PUT: generateWebhookWorkflowJson('PUT'), DELETE: generateWebhookWorkflowJson('DELETE') }; } /** * Get webhook URL for a given n8n instance and HTTP method * * @param n8nUrl - n8n instance URL * @param method - HTTP method * @returns Webhook URL */ export function getWebhookUrl( n8nUrl: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' ): string { const config = WEBHOOK_WORKFLOW_CONFIGS[method]; const baseUrl = n8nUrl.replace(/\/$/, ''); // Remove trailing slash return `${baseUrl}/webhook/${config.path}`; } /** * Validate webhook workflow structure * * Checks if a workflow matches the expected webhook workflow structure. * * @param workflow - Workflow to validate * @param method - Expected HTTP method * @returns true if valid */ export function isValidWebhookWorkflow( workflow: Partial<Workflow>, method: 'GET' | 'POST' | 'PUT' | 'DELETE' ): boolean { if (!workflow.nodes || workflow.nodes.length < 1) { return false; } const webhookNode = workflow.nodes.find(n => n.type === 'n8n-nodes-base.webhook'); if (!webhookNode) { return false; } const params = webhookNode.parameters as any; return params.httpMethod === method; } ``` -------------------------------------------------------------------------------- /tests/unit/utils/template-node-resolver.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import { resolveTemplateNodeTypes } from '../../../src/utils/template-node-resolver'; describe('Template Node Resolver', () => { describe('resolveTemplateNodeTypes', () => { it('should handle bare node names', () => { const result = resolveTemplateNodeTypes(['slack']); expect(result).toContain('n8n-nodes-base.slack'); expect(result).toContain('n8n-nodes-base.slackTrigger'); }); it('should handle HTTP variations', () => { const result = resolveTemplateNodeTypes(['http']); expect(result).toContain('n8n-nodes-base.httpRequest'); expect(result).toContain('n8n-nodes-base.webhook'); }); it('should handle httpRequest variations', () => { const result = resolveTemplateNodeTypes(['httprequest']); expect(result).toContain('n8n-nodes-base.httpRequest'); }); it('should handle partial prefix formats', () => { const result = resolveTemplateNodeTypes(['nodes-base.webhook']); expect(result).toContain('n8n-nodes-base.webhook'); expect(result).not.toContain('nodes-base.webhook'); }); it('should handle langchain nodes', () => { const result = resolveTemplateNodeTypes(['nodes-langchain.agent']); expect(result).toContain('@n8n/n8n-nodes-langchain.agent'); expect(result).not.toContain('nodes-langchain.agent'); }); it('should handle already correct formats', () => { const input = ['n8n-nodes-base.slack', '@n8n/n8n-nodes-langchain.agent']; const result = resolveTemplateNodeTypes(input); expect(result).toContain('n8n-nodes-base.slack'); expect(result).toContain('@n8n/n8n-nodes-langchain.agent'); }); it('should handle Google services', () => { const result = resolveTemplateNodeTypes(['google']); expect(result).toContain('n8n-nodes-base.googleSheets'); expect(result).toContain('n8n-nodes-base.googleDrive'); expect(result).toContain('n8n-nodes-base.googleCalendar'); }); it('should handle database variations', () => { const result = resolveTemplateNodeTypes(['database']); expect(result).toContain('n8n-nodes-base.postgres'); expect(result).toContain('n8n-nodes-base.mysql'); expect(result).toContain('n8n-nodes-base.mongoDb'); expect(result).toContain('n8n-nodes-base.postgresDatabase'); expect(result).toContain('n8n-nodes-base.mysqlDatabase'); }); it('should handle AI/LLM variations', () => { const result = resolveTemplateNodeTypes(['ai']); expect(result).toContain('n8n-nodes-base.openAi'); expect(result).toContain('@n8n/n8n-nodes-langchain.agent'); expect(result).toContain('@n8n/n8n-nodes-langchain.lmChatOpenAi'); }); it('should handle email variations', () => { const result = resolveTemplateNodeTypes(['email']); expect(result).toContain('n8n-nodes-base.emailSend'); expect(result).toContain('n8n-nodes-base.emailReadImap'); expect(result).toContain('n8n-nodes-base.gmail'); expect(result).toContain('n8n-nodes-base.gmailTrigger'); }); it('should handle schedule/cron variations', () => { const result = resolveTemplateNodeTypes(['schedule']); expect(result).toContain('n8n-nodes-base.scheduleTrigger'); expect(result).toContain('n8n-nodes-base.cron'); }); it('should handle multiple inputs', () => { const result = resolveTemplateNodeTypes(['slack', 'webhook', 'http']); expect(result).toContain('n8n-nodes-base.slack'); expect(result).toContain('n8n-nodes-base.slackTrigger'); expect(result).toContain('n8n-nodes-base.webhook'); expect(result).toContain('n8n-nodes-base.httpRequest'); }); it('should not duplicate entries', () => { const result = resolveTemplateNodeTypes(['slack', 'n8n-nodes-base.slack']); const slackCount = result.filter(r => r === 'n8n-nodes-base.slack').length; expect(slackCount).toBe(1); }); it('should handle mixed case inputs', () => { const result = resolveTemplateNodeTypes(['Slack', 'WEBHOOK', 'HttpRequest']); expect(result).toContain('n8n-nodes-base.slack'); expect(result).toContain('n8n-nodes-base.webhook'); expect(result).toContain('n8n-nodes-base.httpRequest'); }); it('should handle common misspellings', () => { const result = resolveTemplateNodeTypes(['postgres', 'postgresql']); expect(result).toContain('n8n-nodes-base.postgres'); expect(result).toContain('n8n-nodes-base.postgresDatabase'); }); it('should handle code/javascript/python variations', () => { const result = resolveTemplateNodeTypes(['javascript', 'python', 'js']); result.forEach(() => { expect(result).toContain('n8n-nodes-base.code'); }); }); it('should handle trigger suffix variations', () => { const result = resolveTemplateNodeTypes(['slacktrigger', 'gmailtrigger']); expect(result).toContain('n8n-nodes-base.slackTrigger'); expect(result).toContain('n8n-nodes-base.gmailTrigger'); }); it('should handle sheet/sheets variations', () => { const result = resolveTemplateNodeTypes(['googlesheet', 'googlesheets']); result.forEach(() => { expect(result).toContain('n8n-nodes-base.googleSheets'); }); }); it('should return empty array for empty input', () => { const result = resolveTemplateNodeTypes([]); expect(result).toEqual([]); }); }); describe('Edge cases', () => { it('should handle undefined-like strings gracefully', () => { const result = resolveTemplateNodeTypes(['undefined', 'null', '']); // Should process them as regular strings expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); }); it('should handle very long node names', () => { const longName = 'a'.repeat(100); const result = resolveTemplateNodeTypes([longName]); expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); }); it('should handle special characters in node names', () => { const result = resolveTemplateNodeTypes(['node-with-dashes', 'node_with_underscores']); expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); }); }); describe('Real-world scenarios from AI agents', () => { it('should handle common AI agent queries', () => { // These are actual queries that AI agents commonly try const testCases = [ { input: ['slack'], shouldContain: 'n8n-nodes-base.slack' }, { input: ['webhook'], shouldContain: 'n8n-nodes-base.webhook' }, { input: ['http'], shouldContain: 'n8n-nodes-base.httpRequest' }, { input: ['email'], shouldContain: 'n8n-nodes-base.gmail' }, { input: ['gpt'], shouldContain: 'n8n-nodes-base.openAi' }, { input: ['chatgpt'], shouldContain: 'n8n-nodes-base.openAi' }, { input: ['agent'], shouldContain: '@n8n/n8n-nodes-langchain.agent' }, { input: ['sql'], shouldContain: 'n8n-nodes-base.postgres' }, { input: ['api'], shouldContain: 'n8n-nodes-base.httpRequest' }, { input: ['csv'], shouldContain: 'n8n-nodes-base.spreadsheetFile' }, ]; testCases.forEach(({ input, shouldContain }) => { const result = resolveTemplateNodeTypes(input); expect(result).toContain(shouldContain); }); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/setup/msw-test-server.ts: -------------------------------------------------------------------------------- ```typescript import { setupServer } from 'msw/node'; import { HttpResponse, http } from 'msw'; import type { RequestHandler } from 'msw'; import { handlers as defaultHandlers } from '../../mocks/n8n-api/handlers'; /** * MSW server instance for integration tests * This is separate from the global MSW setup to allow for more control * in integration tests that may need specific handler configurations */ export const integrationTestServer = setupServer(...defaultHandlers); /** * Enhanced server controls for integration tests */ export const mswTestServer = { /** * Start the server with specific options */ start: (options?: { onUnhandledRequest?: 'error' | 'warn' | 'bypass'; quiet?: boolean; }) => { integrationTestServer.listen({ onUnhandledRequest: options?.onUnhandledRequest || 'warn', }); if (!options?.quiet && process.env.MSW_DEBUG === 'true') { integrationTestServer.events.on('request:start', ({ request }) => { console.log('[Integration MSW] %s %s', request.method, request.url); }); } }, /** * Stop the server */ stop: () => { integrationTestServer.close(); }, /** * Reset handlers to defaults */ reset: () => { integrationTestServer.resetHandlers(); }, /** * Add handlers for a specific test */ use: (...handlers: RequestHandler[]) => { integrationTestServer.use(...handlers); }, /** * Replace all handlers (useful for isolated test scenarios) */ replaceAll: (...handlers: RequestHandler[]) => { integrationTestServer.resetHandlers(...handlers); }, /** * Wait for a specific number of requests to be made */ waitForRequests: (count: number, timeout = 5000): Promise<Request[]> => { return new Promise((resolve, reject) => { const requests: Request[] = []; let timeoutId: NodeJS.Timeout | null = null; // Event handler function to allow cleanup const handleRequest = ({ request }: { request: Request }) => { requests.push(request); if (requests.length === count) { cleanup(); resolve(requests); } }; // Cleanup function to remove listener and clear timeout const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } integrationTestServer.events.removeListener('request:match', handleRequest); }; // Set timeout timeoutId = setTimeout(() => { cleanup(); reject(new Error(`Timeout waiting for ${count} requests. Got ${requests.length}`)); }, timeout); // Add event listener integrationTestServer.events.on('request:match', handleRequest); }); }, /** * Verify no unhandled requests were made */ verifyNoUnhandledRequests: (): Promise<void> => { return new Promise((resolve, reject) => { let hasUnhandled = false; let timeoutId: NodeJS.Timeout | null = null; const handleUnhandled = ({ request }: { request: Request }) => { hasUnhandled = true; cleanup(); reject(new Error(`Unhandled request: ${request.method} ${request.url}`)); }; const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } integrationTestServer.events.removeListener('request:unhandled', handleUnhandled); }; // Add event listener integrationTestServer.events.on('request:unhandled', handleUnhandled); // Give a small delay to allow any pending requests timeoutId = setTimeout(() => { cleanup(); if (!hasUnhandled) { resolve(); } }, 100); }); }, /** * Create a scoped server for a specific test * Automatically starts and stops the server */ withScope: async <T>( handlers: RequestHandler[], testFn: () => Promise<T> ): Promise<T> => { // Save current handlers const currentHandlers = [...defaultHandlers]; try { // Replace with scoped handlers integrationTestServer.resetHandlers(...handlers); // Run the test return await testFn(); } finally { // Restore original handlers integrationTestServer.resetHandlers(...currentHandlers); } } }; /** * Integration test utilities for n8n API mocking */ export const n8nApiMock = { /** * Mock a successful workflow creation */ mockWorkflowCreate: (response?: any) => { return http.post('*/api/v1/workflows', async ({ request }) => { const body = await request.json() as Record<string, any>; return HttpResponse.json({ data: { id: 'test-workflow-id', ...body, ...response, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } }, { status: 201 }); }); }, /** * Mock a workflow validation endpoint */ mockWorkflowValidate: (validationResult: { valid: boolean; errors?: any[] }) => { return http.post('*/api/v1/workflows/validate', async () => { return HttpResponse.json(validationResult); }); }, /** * Mock webhook execution */ mockWebhookExecution: (webhookPath: string, response: any) => { return http.all(`*/webhook/${webhookPath}`, async ({ request }) => { const body = request.body ? await request.json() : undefined; // Simulate webhook processing return HttpResponse.json({ ...response, webhookReceived: { path: webhookPath, method: request.method, body, timestamp: new Date().toISOString() } }); }); }, /** * Mock API error responses */ mockError: (endpoint: string, error: { status: number; message: string; code?: string }) => { return http.all(endpoint, () => { return HttpResponse.json( { message: error.message, code: error.code || 'ERROR', timestamp: new Date().toISOString() }, { status: error.status } ); }); }, /** * Mock rate limiting */ mockRateLimit: (endpoint: string) => { let requestCount = 0; const limit = 5; return http.all(endpoint, () => { requestCount++; if (requestCount > limit) { return HttpResponse.json( { message: 'Rate limit exceeded', code: 'RATE_LIMIT', retryAfter: 60 }, { status: 429, headers: { 'X-RateLimit-Limit': String(limit), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(Date.now() + 60000) } } ); } return HttpResponse.json({ success: true }); }); } }; /** * Test data builders for integration tests */ export const testDataBuilders = { /** * Build a workflow for testing */ workflow: (overrides?: any) => ({ name: 'Integration Test Workflow', nodes: [ { id: 'start', name: 'Start', type: 'n8n-nodes-base.start', typeVersion: 1, position: [250, 300], parameters: {} } ], connections: {}, settings: {}, active: false, ...overrides }), /** * Build an execution result */ execution: (workflowId: string, overrides?: any) => ({ id: `exec_${Date.now()}`, workflowId, status: 'success', mode: 'manual', startedAt: new Date().toISOString(), stoppedAt: new Date().toISOString(), data: { resultData: { runData: {} } }, ...overrides }) }; ``` -------------------------------------------------------------------------------- /scripts/test-expression-format-validation.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node /** * Test script for expression format validation * Tests the validation of expression prefixes and resource locator formats */ const { WorkflowValidator } = require('../dist/services/workflow-validator.js'); const { NodeRepository } = require('../dist/database/node-repository.js'); const { EnhancedConfigValidator } = require('../dist/services/enhanced-config-validator.js'); const { createDatabaseAdapter } = require('../dist/database/database-adapter.js'); const path = require('path'); async function runTests() { // Initialize database const dbPath = path.join(__dirname, '..', 'data', 'nodes.db'); const adapter = await createDatabaseAdapter(dbPath); const db = adapter; const nodeRepository = new NodeRepository(db); const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator); console.log('\n🧪 Testing Expression Format Validation\n'); console.log('=' .repeat(60)); // Test 1: Email node with missing = prefix console.log('\n📝 Test 1: Email Send node - Missing = prefix'); console.log('-'.repeat(40)); const emailWorkflowIncorrect = { nodes: [ { id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0', name: 'Error Handler', type: 'n8n-nodes-base.emailSend', typeVersion: 2.1, position: [-128, 400], parameters: { fromEmail: '{{ $env.ADMIN_EMAIL }}', // INCORRECT - missing = toEmail: '[email protected]', subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', options: {} }, credentials: { smtp: { id: '7AQ08VMFHubmfvzR', name: '[email protected]' } } } ], connections: {} }; const result1 = await validator.validateWorkflow(emailWorkflowIncorrect); if (result1.errors.some(e => e.message.includes('Expression format'))) { console.log('✅ ERROR DETECTED (correct behavior):'); const formatError = result1.errors.find(e => e.message.includes('Expression format')); console.log('\n' + formatError.message); } else { console.log('❌ No expression format error detected (should have detected missing prefix)'); } // Test 2: Email node with correct = prefix console.log('\n📝 Test 2: Email Send node - Correct = prefix'); console.log('-'.repeat(40)); const emailWorkflowCorrect = { nodes: [ { id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0', name: 'Error Handler', type: 'n8n-nodes-base.emailSend', typeVersion: 2.1, position: [-128, 400], parameters: { fromEmail: '={{ $env.ADMIN_EMAIL }}', // CORRECT - has = toEmail: '[email protected]', subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', options: {} } } ], connections: {} }; const result2 = await validator.validateWorkflow(emailWorkflowCorrect); if (result2.errors.some(e => e.message.includes('Expression format'))) { console.log('❌ Unexpected expression format error (should accept = prefix)'); } else { console.log('✅ No expression format errors (correct!)'); } // Test 3: GitHub node without resource locator format console.log('\n📝 Test 3: GitHub node - Missing resource locator format'); console.log('-'.repeat(40)); const githubWorkflowIncorrect = { nodes: [ { id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491', name: 'Send Welcome Comment', type: 'n8n-nodes-base.github', typeVersion: 1.1, position: [-240, 96], parameters: { operation: 'createComment', owner: '{{ $vars.GITHUB_OWNER }}', // INCORRECT - needs RL format repository: '{{ $vars.GITHUB_REPO }}', // INCORRECT - needs RL format issueNumber: null, body: '👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // INCORRECT - missing = }, credentials: { githubApi: { id: 'edgpwh6ldYN07MXx', name: 'GitHub account' } } } ], connections: {} }; const result3 = await validator.validateWorkflow(githubWorkflowIncorrect); const formatErrors = result3.errors.filter(e => e.message.includes('Expression format')); console.log(`\nFound ${formatErrors.length} expression format errors:`); if (formatErrors.length >= 3) { console.log('✅ All format issues detected:'); formatErrors.forEach((error, index) => { const field = error.message.match(/Field '([^']+)'/)?.[1] || 'unknown'; console.log(` ${index + 1}. Field '${field}' - ${error.message.includes('resource locator') ? 'Needs RL format' : 'Missing = prefix'}`); }); } else { console.log('❌ Not all format issues detected'); } // Test 4: GitHub node with correct resource locator format console.log('\n📝 Test 4: GitHub node - Correct resource locator format'); console.log('-'.repeat(40)); const githubWorkflowCorrect = { nodes: [ { id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491', name: 'Send Welcome Comment', type: 'n8n-nodes-base.github', typeVersion: 1.1, position: [-240, 96], parameters: { operation: 'createComment', owner: { __rl: true, value: '={{ $vars.GITHUB_OWNER }}', // CORRECT - RL format with = mode: 'expression' }, repository: { __rl: true, value: '={{ $vars.GITHUB_REPO }}', // CORRECT - RL format with = mode: 'expression' }, issueNumber: 123, body: '=👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // CORRECT - has = } } ], connections: {} }; const result4 = await validator.validateWorkflow(githubWorkflowCorrect); const formatErrors4 = result4.errors.filter(e => e.message.includes('Expression format')); if (formatErrors4.length === 0) { console.log('✅ No expression format errors (correct!)'); } else { console.log(`❌ Unexpected expression format errors: ${formatErrors4.length}`); formatErrors4.forEach(e => console.log(' - ' + e.message.split('\n')[0])); } // Test 5: Mixed content expressions console.log('\n📝 Test 5: Mixed content with expressions'); console.log('-'.repeat(40)); const mixedContentWorkflow = { nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [0, 0], parameters: { url: 'https://api.example.com/users/{{ $json.userId }}', // INCORRECT headers: { 'Authorization': '=Bearer {{ $env.API_TOKEN }}' // CORRECT } } } ], connections: {} }; const result5 = await validator.validateWorkflow(mixedContentWorkflow); const urlError = result5.errors.find(e => e.message.includes('url') && e.message.includes('Expression format')); if (urlError) { console.log('✅ Mixed content error detected for URL field'); console.log(' Should be: "=https://api.example.com/users/{{ $json.userId }}"'); } else { console.log('❌ Mixed content error not detected'); } console.log('\n' + '='.repeat(60)); console.log('\n✨ Expression Format Validation Summary:'); console.log(' - Detects missing = prefix in expressions'); console.log(' - Identifies fields needing resource locator format'); console.log(' - Provides clear correction examples'); console.log(' - Handles mixed literal and expression content'); // Close database db.close(); } runTests().catch(error => { console.error('Test failed:', error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/workflows/get-workflow-details.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Tests: handleGetWorkflowDetails * * Tests workflow details retrieval against a real n8n instance. * Covers basic workflows, metadata, version history, and execution stats. */ import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context'; import { getTestN8nClient } from '../utils/n8n-client'; import { N8nApiClient } from '../../../../src/services/n8n-api-client'; import { SIMPLE_WEBHOOK_WORKFLOW } from '../utils/fixtures'; import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers'; import { createMcpContext } from '../utils/mcp-context'; import { InstanceContext } from '../../../../src/types/instance-context'; import { handleGetWorkflowDetails } from '../../../../src/mcp/handlers-n8n-manager'; describe('Integration: handleGetWorkflowDetails', () => { let context: TestContext; let client: N8nApiClient; let mcpContext: InstanceContext; beforeEach(() => { context = createTestContext(); client = getTestN8nClient(); mcpContext = createMcpContext(); }); afterEach(async () => { await context.cleanup(); }); afterAll(async () => { if (!process.env.CI) { await cleanupOrphanedWorkflows(); } }); // ====================================================================== // Basic Workflow Details // ====================================================================== describe('Basic Workflow', () => { it('should retrieve workflow with basic details', async () => { // Create a simple workflow const workflow = { ...SIMPLE_WEBHOOK_WORKFLOW, name: createTestWorkflowName('Get Details - Basic'), tags: ['mcp-integration-test'] }; const created = await client.createWorkflow(workflow); expect(created).toBeDefined(); expect(created.id).toBeTruthy(); if (!created.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(created.id); // Retrieve detailed workflow information using MCP handler const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext); // Verify MCP response structure expect(response.success).toBe(true); expect(response.data).toBeDefined(); // handleGetWorkflowDetails returns { workflow, executionStats, hasWebhookTrigger, webhookPath } const details = (response.data as any).workflow; // Verify basic details expect(details).toBeDefined(); expect(details.id).toBe(created.id); expect(details.name).toBe(workflow.name); expect(details.createdAt).toBeDefined(); expect(details.updatedAt).toBeDefined(); expect(details.active).toBeDefined(); // Verify metadata fields expect(details.versionId).toBeDefined(); }); }); // ====================================================================== // Workflow with Metadata // ====================================================================== describe('Workflow with Metadata', () => { it('should retrieve workflow with tags and settings metadata', async () => { // Create workflow with rich metadata const workflow = { ...SIMPLE_WEBHOOK_WORKFLOW, name: createTestWorkflowName('Get Details - With Metadata'), tags: [ 'mcp-integration-test', 'test-category', 'integration' ], settings: { executionOrder: 'v1' as const, timezone: 'America/New_York' } }; const created = await client.createWorkflow(workflow); expect(created).toBeDefined(); expect(created.id).toBeTruthy(); if (!created.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(created.id); // Retrieve workflow details using MCP handler const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext); expect(response.success).toBe(true); const details = (response.data as any).workflow; // Verify metadata is present (tags may be undefined in API response) // Note: n8n API behavior for tags varies - they may not be returned // in GET requests even if set during creation if (details.tags) { expect(details.tags.length).toBeGreaterThanOrEqual(0); } // Verify settings expect(details.settings).toBeDefined(); expect(details.settings!.executionOrder).toBe('v1'); expect(details.settings!.timezone).toBe('America/New_York'); }); }); // ====================================================================== // Version History // ====================================================================== describe('Version History', () => { it('should track version changes after updates', async () => { // Create initial workflow const workflow = { ...SIMPLE_WEBHOOK_WORKFLOW, name: createTestWorkflowName('Get Details - Version History'), tags: ['mcp-integration-test'] }; const created = await client.createWorkflow(workflow); expect(created).toBeDefined(); expect(created.id).toBeTruthy(); if (!created.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(created.id); // Get initial version using MCP handler const initialResponse = await handleGetWorkflowDetails({ id: created.id }, mcpContext); expect(initialResponse.success).toBe(true); const initialDetails = (initialResponse.data as any).workflow; const initialVersionId = initialDetails.versionId; const initialUpdatedAt = initialDetails.updatedAt; // Update the workflow await client.updateWorkflow(created.id, { name: createTestWorkflowName('Get Details - Version History (Updated)'), nodes: workflow.nodes, connections: workflow.connections }); // Get updated details using MCP handler const updatedResponse = await handleGetWorkflowDetails({ id: created.id }, mcpContext); expect(updatedResponse.success).toBe(true); const updatedDetails = (updatedResponse.data as any).workflow; // Verify version changed expect(updatedDetails.versionId).toBeDefined(); expect(updatedDetails.updatedAt).not.toBe(initialUpdatedAt); // Version ID should have changed after update expect(updatedDetails.versionId).not.toBe(initialVersionId); }); }); // ====================================================================== // Execution Statistics // ====================================================================== describe('Execution Statistics', () => { it('should include execution-related fields in details', async () => { // Create workflow const workflow = { ...SIMPLE_WEBHOOK_WORKFLOW, name: createTestWorkflowName('Get Details - Execution Stats'), tags: ['mcp-integration-test'] }; const created = await client.createWorkflow(workflow); expect(created).toBeDefined(); expect(created.id).toBeTruthy(); if (!created.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(created.id); // Retrieve workflow details using MCP handler const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext); expect(response.success).toBe(true); const details = (response.data as any).workflow; // Verify execution-related fields exist // Note: New workflows won't have executions, but fields should be present expect(details).toHaveProperty('active'); // The workflow should start inactive expect(details.active).toBe(false); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/test-env-example.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Example test demonstrating test environment configuration usage */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { getTestConfig, getTestTimeout, isFeatureEnabled, isTestMode, loadTestEnvironment } from '@tests/setup/test-env'; import { withEnvOverrides, createTestDatabasePath, getMockApiUrl, measurePerformance, createTestLogger, waitForCondition } from '@tests/helpers/env-helpers'; describe('Test Environment Configuration Example', () => { let config: ReturnType<typeof getTestConfig>; let logger: ReturnType<typeof createTestLogger>; beforeAll(() => { // Initialize config inside beforeAll to ensure environment is loaded config = getTestConfig(); logger = createTestLogger('test-env-example'); logger.info('Test suite starting with configuration:', { environment: config.nodeEnv, database: config.database.path, apiUrl: config.api.url }); }); afterAll(() => { logger.info('Test suite completed'); }); it('should be in test mode', () => { const testConfig = getTestConfig(); expect(isTestMode()).toBe(true); expect(testConfig.nodeEnv).toBe('test'); expect(testConfig.isTest).toBe(true); }); it('should have proper database configuration', () => { const testConfig = getTestConfig(); expect(testConfig.database.path).toBeDefined(); expect(testConfig.database.rebuildOnStart).toBe(false); expect(testConfig.database.seedData).toBe(true); }); it.skip('should have mock API configuration', () => { const testConfig = getTestConfig(); // Add debug logging for CI if (process.env.CI) { console.log('CI Environment Debug:', { NODE_ENV: process.env.NODE_ENV, N8N_API_URL: process.env.N8N_API_URL, N8N_API_KEY: process.env.N8N_API_KEY, configUrl: testConfig.api.url, configKey: testConfig.api.key }); } expect(testConfig.api.url).toMatch(/mock-api/); expect(testConfig.api.key).toBe('test-api-key-12345'); }); it('should respect test timeouts', { timeout: getTestTimeout('unit') }, async () => { const timeout = getTestTimeout('unit'); expect(timeout).toBe(5000); // Simulate async operation await new Promise(resolve => setTimeout(resolve, 100)); }); it('should support environment overrides', () => { const testConfig = getTestConfig(); const originalLogLevel = testConfig.logging.level; const result = withEnvOverrides({ LOG_LEVEL: 'debug', DEBUG: 'true' }, () => { const newConfig = getTestConfig(); expect(newConfig.logging.level).toBe('debug'); expect(newConfig.logging.debug).toBe(true); return 'success'; }); expect(result).toBe('success'); const configAfter = getTestConfig(); expect(configAfter.logging.level).toBe(originalLogLevel); }); it('should generate unique test database paths', () => { const path1 = createTestDatabasePath('feature1'); const path2 = createTestDatabasePath('feature1'); if (path1 !== ':memory:') { expect(path1).not.toBe(path2); expect(path1).toMatch(/test-feature1-\d+-\w+\.db$/); } }); it('should construct mock API URLs', () => { const testConfig = getTestConfig(); const baseUrl = getMockApiUrl(); const endpointUrl = getMockApiUrl('/nodes'); expect(baseUrl).toBe(testConfig.api.url); expect(endpointUrl).toBe(`${testConfig.api.url}/nodes`); }); it.skipIf(!isFeatureEnabled('mockExternalApis'))('should check feature flags', () => { const testConfig = getTestConfig(); expect(testConfig.features.mockExternalApis).toBe(true); expect(isFeatureEnabled('mockExternalApis')).toBe(true); }); it('should measure performance', () => { const measure = measurePerformance('test-operation'); // Test the performance measurement utility structure and behavior // rather than relying on timing precision which is unreliable in CI // Capture initial state const startTime = performance.now(); // Add some marks measure.mark('start-processing'); // Do some minimal synchronous work let sum = 0; for (let i = 0; i < 10000; i++) { sum += i; } measure.mark('mid-processing'); // Do a bit more work for (let i = 0; i < 10000; i++) { sum += i * 2; } const results = measure.end(); const endTime = performance.now(); // Test the utility's correctness rather than exact timing expect(results).toHaveProperty('total'); expect(results).toHaveProperty('marks'); expect(typeof results.total).toBe('number'); expect(results.total).toBeGreaterThan(0); // Verify marks structure expect(results.marks).toHaveProperty('start-processing'); expect(results.marks).toHaveProperty('mid-processing'); expect(typeof results.marks['start-processing']).toBe('number'); expect(typeof results.marks['mid-processing']).toBe('number'); // Verify logical order of marks (this should always be true) expect(results.marks['start-processing']).toBeLessThan(results.marks['mid-processing']); expect(results.marks['start-processing']).toBeGreaterThanOrEqual(0); expect(results.marks['mid-processing']).toBeLessThan(results.total); // Verify the total time is reasonable (should be between manual measurements) const manualTotal = endTime - startTime; expect(results.total).toBeLessThanOrEqual(manualTotal + 1); // Allow 1ms tolerance // Verify work was actually done expect(sum).toBeGreaterThan(0); }); it('should wait for conditions', async () => { let counter = 0; const incrementCounter = setInterval(() => counter++, 100); try { await waitForCondition( () => counter >= 3, { timeout: 1000, interval: 50, message: 'Counter did not reach 3' } ); expect(counter).toBeGreaterThanOrEqual(3); } finally { clearInterval(incrementCounter); } }); it('should have proper logging configuration', () => { const testConfig = getTestConfig(); expect(testConfig.logging.level).toBe('error'); expect(testConfig.logging.debug).toBe(false); expect(testConfig.logging.showStack).toBe(true); // Logger should respect configuration logger.debug('This should not appear in test output'); logger.error('This should appear in test output'); }); it('should have performance thresholds', () => { const testConfig = getTestConfig(); expect(testConfig.performance.thresholds.apiResponse).toBe(100); expect(testConfig.performance.thresholds.dbQuery).toBe(50); expect(testConfig.performance.thresholds.nodeParse).toBe(200); }); it('should disable caching and rate limiting in tests', () => { const testConfig = getTestConfig(); expect(testConfig.cache.enabled).toBe(false); expect(testConfig.cache.ttl).toBe(0); expect(testConfig.rateLimiting.max).toBe(0); expect(testConfig.rateLimiting.window).toBe(0); }); it('should configure test paths', () => { const testConfig = getTestConfig(); expect(testConfig.paths.fixtures).toBe('./tests/fixtures'); expect(testConfig.paths.data).toBe('./tests/data'); expect(testConfig.paths.snapshots).toBe('./tests/__snapshots__'); }); it('should support MSW configuration', () => { // Ensure test environment is loaded if (!process.env.MSW_ENABLED) { loadTestEnvironment(); } const testConfig = getTestConfig(); expect(testConfig.mocking.msw.enabled).toBe(true); expect(testConfig.mocking.msw.apiDelay).toBe(0); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/examples/using-n8n-nodes-base-mock.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { getNodeTypes, mockNodeBehavior, resetAllMocks } from '../__mocks__/n8n-nodes-base'; // Example service that uses n8n-nodes-base class WorkflowService { async getNodeDescription(nodeName: string) { const nodeTypes = getNodeTypes(); const node = nodeTypes.getByName(nodeName); return node?.description; } async executeNode(nodeName: string, context: any) { const nodeTypes = getNodeTypes(); const node = nodeTypes.getByName(nodeName); if (!node?.execute) { throw new Error(`Node ${nodeName} does not have an execute method`); } return node.execute.call(context); } async validateSlackMessage(channel: string, text: string) { if (!channel || !text) { throw new Error('Channel and text are required'); } const nodeTypes = getNodeTypes(); const slackNode = nodeTypes.getByName('slack'); if (!slackNode) { throw new Error('Slack node not found'); } // Check if required properties exist const channelProp = slackNode.description.properties.find(p => p.name === 'channel'); const textProp = slackNode.description.properties.find(p => p.name === 'text'); return !!(channelProp && textProp); } } // Mock the module at the top level vi.mock('n8n-nodes-base', () => { const { getNodeTypes: mockGetNodeTypes } = require('../__mocks__/n8n-nodes-base'); return { getNodeTypes: mockGetNodeTypes }; }); describe('WorkflowService with n8n-nodes-base mock', () => { let service: WorkflowService; beforeEach(() => { resetAllMocks(); service = new WorkflowService(); }); describe('getNodeDescription', () => { it('should get webhook node description', async () => { const description = await service.getNodeDescription('webhook'); expect(description).toBeDefined(); expect(description?.name).toBe('webhook'); expect(description?.group).toContain('trigger'); expect(description?.webhooks).toBeDefined(); }); it('should get httpRequest node description', async () => { const description = await service.getNodeDescription('httpRequest'); expect(description).toBeDefined(); expect(description?.name).toBe('httpRequest'); expect(description?.version).toBe(3); const methodProp = description?.properties.find(p => p.name === 'method'); expect(methodProp).toBeDefined(); expect(methodProp?.options).toHaveLength(6); }); }); describe('executeNode', () => { it('should execute httpRequest node with custom response', async () => { // Override the httpRequest node behavior for this test mockNodeBehavior('httpRequest', { execute: vi.fn(async function(this: any) { const url = this.getNodeParameter('url', 0); return [[{ json: { statusCode: 200, url, customData: 'mocked response' } }]]; }) }); const mockContext = { getInputData: vi.fn(() => [{ json: { input: 'data' } }]), getNodeParameter: vi.fn((name: string) => { if (name === 'url') return 'https://test.com/api'; return ''; }) }; const result = await service.executeNode('httpRequest', mockContext); expect(result).toBeDefined(); expect(result[0][0].json).toMatchObject({ statusCode: 200, url: 'https://test.com/api', customData: 'mocked response' }); }); it('should execute slack node and track calls', async () => { const mockContext = { getInputData: vi.fn(() => [{ json: { message: 'test' } }]), getNodeParameter: vi.fn((name: string, index: number) => { const params: Record<string, string> = { resource: 'message', operation: 'post', channel: '#general', text: 'Hello from test!' }; return params[name] || ''; }), getCredentials: vi.fn(async () => ({ token: 'mock-token' })) }; const result = await service.executeNode('slack', mockContext); expect(result).toBeDefined(); expect(result[0][0].json).toMatchObject({ ok: true, channel: '#general', message: { text: 'Hello from test!' } }); // Verify the mock was called expect(mockContext.getNodeParameter).toHaveBeenCalledWith('channel', 0, ''); expect(mockContext.getNodeParameter).toHaveBeenCalledWith('text', 0, ''); }); it('should throw error for non-executable node', async () => { // Create a trigger-only node mockNodeBehavior('webhook', { execute: undefined // Remove execute method }); await expect( service.executeNode('webhook', {}) ).rejects.toThrow('Node webhook does not have an execute method'); }); }); describe('validateSlackMessage', () => { it('should validate slack message parameters', async () => { const isValid = await service.validateSlackMessage('#general', 'Hello'); expect(isValid).toBe(true); }); it('should throw error for missing parameters', async () => { await expect( service.validateSlackMessage('', 'Hello') ).rejects.toThrow('Channel and text are required'); await expect( service.validateSlackMessage('#general', '') ).rejects.toThrow('Channel and text are required'); }); it('should handle missing slack node', async () => { // Save the original mock implementation const originalImplementation = vi.mocked(getNodeTypes).getMockImplementation(); // Override getNodeTypes to return undefined for slack vi.mocked(getNodeTypes).mockImplementation(() => ({ getByName: vi.fn((name: string) => { if (name === 'slack') return undefined; // Return the actual mock implementation for other nodes const actualRegistry = originalImplementation ? originalImplementation() : getNodeTypes(); return actualRegistry.getByName(name); }), getByNameAndVersion: vi.fn() })); await expect( service.validateSlackMessage('#general', 'Hello') ).rejects.toThrow('Slack node not found'); // Restore the original implementation if (originalImplementation) { vi.mocked(getNodeTypes).mockImplementation(originalImplementation); } }); }); describe('complex workflow scenarios', () => { it('should handle if node branching', async () => { const mockContext = { getInputData: vi.fn(() => [ { json: { status: 'active' } }, { json: { status: 'inactive' } }, { json: { status: 'active' } }, ]), getNodeParameter: vi.fn() }; const result = await service.executeNode('if', mockContext); expect(result).toHaveLength(2); // true and false branches expect(result[0]).toHaveLength(2); // items at index 0 and 2 expect(result[1]).toHaveLength(1); // item at index 1 }); it('should handle merge node combining inputs', async () => { const mockContext = { getInputData: vi.fn((inputIndex?: number) => { if (inputIndex === 0) return [{ json: { source: 'input1' } }]; if (inputIndex === 1) return [{ json: { source: 'input2' } }]; return [{ json: { source: 'input1' } }]; }), getNodeParameter: vi.fn(() => 'append') }; const result = await service.executeNode('merge', mockContext); expect(result).toBeDefined(); expect(result[0]).toHaveLength(1); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/universal-expression-validator.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import { UniversalExpressionValidator } from '../../../src/services/universal-expression-validator'; describe('UniversalExpressionValidator', () => { describe('validateExpressionPrefix', () => { it('should detect missing prefix in pure expression', () => { const result = UniversalExpressionValidator.validateExpressionPrefix('{{ $json.value }}'); expect(result.isValid).toBe(false); expect(result.hasExpression).toBe(true); expect(result.needsPrefix).toBe(true); expect(result.isMixedContent).toBe(false); expect(result.confidence).toBe(1.0); expect(result.suggestion).toBe('={{ $json.value }}'); }); it('should detect missing prefix in mixed content', () => { const result = UniversalExpressionValidator.validateExpressionPrefix( 'Hello {{ $json.name }}' ); expect(result.isValid).toBe(false); expect(result.hasExpression).toBe(true); expect(result.needsPrefix).toBe(true); expect(result.isMixedContent).toBe(true); expect(result.confidence).toBe(1.0); expect(result.suggestion).toBe('=Hello {{ $json.name }}'); }); it('should accept properly prefixed expression', () => { const result = UniversalExpressionValidator.validateExpressionPrefix('={{ $json.value }}'); expect(result.isValid).toBe(true); expect(result.hasExpression).toBe(true); expect(result.needsPrefix).toBe(false); expect(result.confidence).toBe(1.0); }); it('should accept properly prefixed mixed content', () => { const result = UniversalExpressionValidator.validateExpressionPrefix( '=Hello {{ $json.name }}!' ); expect(result.isValid).toBe(true); expect(result.hasExpression).toBe(true); expect(result.isMixedContent).toBe(true); expect(result.confidence).toBe(1.0); }); it('should ignore non-string values', () => { const result = UniversalExpressionValidator.validateExpressionPrefix(123); expect(result.isValid).toBe(true); expect(result.hasExpression).toBe(false); expect(result.confidence).toBe(1.0); }); it('should ignore strings without expressions', () => { const result = UniversalExpressionValidator.validateExpressionPrefix('plain text'); expect(result.isValid).toBe(true); expect(result.hasExpression).toBe(false); expect(result.confidence).toBe(1.0); }); }); describe('validateExpressionSyntax', () => { it('should detect unclosed brackets', () => { const result = UniversalExpressionValidator.validateExpressionSyntax('={{ $json.value }'); expect(result.isValid).toBe(false); expect(result.explanation).toContain('Unmatched expression brackets'); }); it('should detect empty expressions', () => { const result = UniversalExpressionValidator.validateExpressionSyntax('={{ }}'); expect(result.isValid).toBe(false); expect(result.explanation).toContain('Empty expression'); }); it('should accept valid syntax', () => { const result = UniversalExpressionValidator.validateExpressionSyntax('={{ $json.value }}'); expect(result.isValid).toBe(true); expect(result.hasExpression).toBe(true); }); it('should handle multiple expressions', () => { const result = UniversalExpressionValidator.validateExpressionSyntax( '={{ $json.first }} and {{ $json.second }}' ); expect(result.isValid).toBe(true); expect(result.hasExpression).toBe(true); expect(result.isMixedContent).toBe(true); }); }); describe('validateCommonPatterns', () => { it('should detect template literal syntax', () => { const result = UniversalExpressionValidator.validateCommonPatterns('={{ ${json.value} }}'); expect(result.isValid).toBe(false); expect(result.explanation).toContain('Template literal syntax'); }); it('should detect double prefix', () => { const result = UniversalExpressionValidator.validateCommonPatterns('={{ =$json.value }}'); expect(result.isValid).toBe(false); expect(result.explanation).toContain('Double prefix'); }); it('should detect nested brackets', () => { const result = UniversalExpressionValidator.validateCommonPatterns( '={{ $json.items[{{ $json.index }}] }}' ); expect(result.isValid).toBe(false); expect(result.explanation).toContain('Nested brackets'); }); it('should accept valid patterns', () => { const result = UniversalExpressionValidator.validateCommonPatterns( '={{ $json.items[$json.index] }}' ); expect(result.isValid).toBe(true); }); }); describe('validate (comprehensive)', () => { it('should return all validation issues', () => { const results = UniversalExpressionValidator.validate('{{ ${json.value} }}'); expect(results.length).toBeGreaterThan(0); const issues = results.filter(r => !r.isValid); expect(issues.length).toBeGreaterThan(0); // Should detect both missing prefix and template literal syntax const prefixIssue = issues.find(i => i.needsPrefix); const patternIssue = issues.find(i => i.explanation.includes('Template literal')); expect(prefixIssue).toBeTruthy(); expect(patternIssue).toBeTruthy(); }); it('should return success for valid expression', () => { const results = UniversalExpressionValidator.validate('={{ $json.value }}'); expect(results).toHaveLength(1); expect(results[0].isValid).toBe(true); expect(results[0].confidence).toBe(1.0); }); it('should handle non-expression strings', () => { const results = UniversalExpressionValidator.validate('plain text'); expect(results).toHaveLength(1); expect(results[0].isValid).toBe(true); expect(results[0].hasExpression).toBe(false); }); }); describe('getCorrectedValue', () => { it('should add prefix to expression', () => { const corrected = UniversalExpressionValidator.getCorrectedValue('{{ $json.value }}'); expect(corrected).toBe('={{ $json.value }}'); }); it('should add prefix to mixed content', () => { const corrected = UniversalExpressionValidator.getCorrectedValue( 'Hello {{ $json.name }}' ); expect(corrected).toBe('=Hello {{ $json.name }}'); }); it('should not modify already prefixed expressions', () => { const corrected = UniversalExpressionValidator.getCorrectedValue('={{ $json.value }}'); expect(corrected).toBe('={{ $json.value }}'); }); it('should not modify non-expressions', () => { const corrected = UniversalExpressionValidator.getCorrectedValue('plain text'); expect(corrected).toBe('plain text'); }); }); describe('hasMixedContent', () => { it('should detect URLs with expressions', () => { const result = UniversalExpressionValidator.validateExpressionPrefix( 'https://api.example.com/users/{{ $json.id }}' ); expect(result.isMixedContent).toBe(true); }); it('should detect text with expressions', () => { const result = UniversalExpressionValidator.validateExpressionPrefix( 'Welcome {{ $json.name }} to our service' ); expect(result.isMixedContent).toBe(true); }); it('should identify pure expressions', () => { const result = UniversalExpressionValidator.validateExpressionPrefix('{{ $json.value }}'); expect(result.isMixedContent).toBe(false); }); it('should identify pure expressions with spaces', () => { const result = UniversalExpressionValidator.validateExpressionPrefix( ' {{ $json.value }} ' ); expect(result.isMixedContent).toBe(false); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/test-helpers.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { CallToolRequestSchema, ListToolsRequestSchema, InitializeRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { N8NDocumentationMCPServer } from '../../../src/mcp/server'; let sharedMcpServer: N8NDocumentationMCPServer | null = null; export class TestableN8NMCPServer { private mcpServer: N8NDocumentationMCPServer; private server: Server; private transports = new Set<Transport>(); private connections = new Set<any>(); private static instanceCount = 0; private testDbPath: string; constructor() { // Use a unique test database for each instance to avoid conflicts // This prevents concurrent test issues with database locking const instanceId = TestableN8NMCPServer.instanceCount++; this.testDbPath = `/tmp/n8n-mcp-test-${process.pid}-${instanceId}.db`; process.env.NODE_DB_PATH = this.testDbPath; this.server = new Server({ name: 'n8n-documentation-mcp', version: '1.0.0' }, { capabilities: { tools: {} } }); this.mcpServer = new N8NDocumentationMCPServer(); this.setupHandlers(); } private setupHandlers() { // Initialize handler this.server.setRequestHandler(InitializeRequestSchema, async () => { return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'n8n-documentation-mcp', version: '1.0.0' } }; }); // List tools handler this.server.setRequestHandler(ListToolsRequestSchema, async () => { // Import the tools directly from the tools module const { n8nDocumentationToolsFinal } = await import('../../../src/mcp/tools'); const { n8nManagementTools } = await import('../../../src/mcp/tools-n8n-manager'); const { isN8nApiConfigured } = await import('../../../src/config/n8n-api'); // Combine documentation tools with management tools if API is configured const tools = [...n8nDocumentationToolsFinal]; if (isN8nApiConfigured()) { tools.push(...n8nManagementTools); } return { tools }; }); // Call tool handler this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { // The mcpServer.executeTool returns raw data, we need to wrap it in the MCP response format const result = await this.mcpServer.executeTool(request.params.name, request.params.arguments || {}); return { content: [ { type: 'text' as const, text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) } ] }; } catch (error: any) { // If it's already an MCP error, throw it as is if (error.code && error.message) { throw error; } // Otherwise, wrap it in an MCP error throw new McpError( ErrorCode.InternalError, error.message || 'Unknown error' ); } }); } async initialize(): Promise<void> { // Copy production database to test location for realistic testing try { const fs = await import('fs'); const path = await import('path'); const prodDbPath = path.join(process.cwd(), 'data', 'nodes.db'); if (await fs.promises.access(prodDbPath).then(() => true).catch(() => false)) { await fs.promises.copyFile(prodDbPath, this.testDbPath); } } catch (error) { // Ignore copy errors, database will be created fresh } // The MCP server initializes its database lazily // We can trigger initialization by calling executeTool try { await this.mcpServer.executeTool('get_database_statistics', {}); } catch (error) { // Ignore errors, we just want to trigger initialization } } async connectToTransport(transport: Transport): Promise<void> { // Ensure transport has required properties before connecting if (!transport || typeof transport !== 'object') { throw new Error('Invalid transport provided'); } // Set up any missing transport handlers to prevent "Cannot set properties of undefined" errors if (transport && typeof transport === 'object') { const transportAny = transport as any; if (transportAny.serverTransport && !transportAny.serverTransport.onclose) { transportAny.serverTransport.onclose = () => {}; } } // Track this transport for cleanup this.transports.add(transport); const connection = await this.server.connect(transport); this.connections.add(connection); } async close(): Promise<void> { // Use a timeout to prevent hanging during cleanup const closeTimeout = new Promise<void>((resolve) => { setTimeout(() => { console.warn('TestableN8NMCPServer close timeout - forcing cleanup'); resolve(); }, 3000); }); const performClose = async () => { // Close all connections first with timeout protection const connectionPromises = Array.from(this.connections).map(async (connection) => { const connTimeout = new Promise<void>((resolve) => setTimeout(resolve, 500)); try { if (connection && typeof connection.close === 'function') { await Promise.race([connection.close(), connTimeout]); } } catch (error) { // Ignore errors during connection cleanup } }); await Promise.allSettled(connectionPromises); this.connections.clear(); // Close all tracked transports with timeout protection const transportPromises: Promise<void>[] = []; for (const transport of this.transports) { const transportTimeout = new Promise<void>((resolve) => setTimeout(resolve, 500)); try { // Force close all transports const transportAny = transport as any; // Try different close methods if (transportAny.close && typeof transportAny.close === 'function') { transportPromises.push( Promise.race([transportAny.close(), transportTimeout]) ); } if (transportAny.serverTransport?.close) { transportPromises.push( Promise.race([transportAny.serverTransport.close(), transportTimeout]) ); } if (transportAny.clientTransport?.close) { transportPromises.push( Promise.race([transportAny.clientTransport.close(), transportTimeout]) ); } } catch (error) { // Ignore errors during transport cleanup } } // Wait for all transports to close with timeout await Promise.allSettled(transportPromises); // Clear the transports set this.transports.clear(); // Don't shut down the shared MCP server instance }; // Race between actual close and timeout await Promise.race([performClose(), closeTimeout]); // Clean up test database if (this.testDbPath) { try { const fs = await import('fs'); await fs.promises.unlink(this.testDbPath).catch(() => {}); await fs.promises.unlink(`${this.testDbPath}-shm`).catch(() => {}); await fs.promises.unlink(`${this.testDbPath}-wal`).catch(() => {}); } catch (error) { // Ignore cleanup errors } } } static async shutdownShared(): Promise<void> { if (sharedMcpServer) { await sharedMcpServer.shutdown(); sharedMcpServer = null; } } } ``` -------------------------------------------------------------------------------- /scripts/test-error-output-validation.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env npx tsx /** * Test script for error output validation improvements * Tests both incorrect and correct error output configurations */ import { WorkflowValidator } from '../dist/services/workflow-validator.js'; import { NodeRepository } from '../dist/database/node-repository.js'; import { EnhancedConfigValidator } from '../dist/services/enhanced-config-validator.js'; import { DatabaseAdapter } from '../dist/database/database-adapter.js'; import { Logger } from '../dist/utils/logger.js'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const logger = new Logger({ prefix: '[TestErrorValidation]' }); async function runTests() { // Initialize database const dbPath = path.join(__dirname, '..', 'data', 'n8n-nodes.db'); const adapter = new DatabaseAdapter(); adapter.initialize({ type: 'better-sqlite3', filename: dbPath }); const db = adapter.getDatabase(); const nodeRepository = new NodeRepository(db); const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator); console.log('\n🧪 Testing Error Output Validation Improvements\n'); console.log('=' .repeat(60)); // Test 1: Incorrect configuration - multiple nodes in same array console.log('\n📝 Test 1: INCORRECT - Multiple nodes in main[0]'); console.log('-'.repeat(40)); const incorrectWorkflow = { nodes: [ { id: '132ef0dc-87af-41de-a95d-cabe3a0a5342', name: 'Validate Input', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [-400, 64] as [number, number], parameters: {} }, { id: '5dedf217-63f9-409f-b34e-7780b22e199a', name: 'Filter URLs', type: 'n8n-nodes-base.filter', typeVersion: 2.2, position: [-176, 64] as [number, number], parameters: {} }, { id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3', name: 'Error Response1', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.5, position: [-160, 240] as [number, number], parameters: {} } ], connections: { 'Validate Input': { main: [ [ { node: 'Filter URLs', type: 'main', index: 0 }, { node: 'Error Response1', type: 'main', index: 0 } // WRONG! ] ] } } }; const result1 = await validator.validateWorkflow(incorrectWorkflow); if (result1.errors.length > 0) { console.log('❌ ERROR DETECTED (as expected):'); const errorMessage = result1.errors.find(e => e.message.includes('Incorrect error output configuration') ); if (errorMessage) { console.log('\n' + errorMessage.message); } } else { console.log('✅ No errors found (but should have detected the issue!)'); } // Test 2: Correct configuration - separate arrays console.log('\n📝 Test 2: CORRECT - Separate main[0] and main[1]'); console.log('-'.repeat(40)); const correctWorkflow = { nodes: [ { id: '132ef0dc-87af-41de-a95d-cabe3a0a5342', name: 'Validate Input', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [-400, 64] as [number, number], parameters: {}, onError: 'continueErrorOutput' as const }, { id: '5dedf217-63f9-409f-b34e-7780b22e199a', name: 'Filter URLs', type: 'n8n-nodes-base.filter', typeVersion: 2.2, position: [-176, 64] as [number, number], parameters: {} }, { id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3', name: 'Error Response1', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.5, position: [-160, 240] as [number, number], parameters: {} } ], connections: { 'Validate Input': { main: [ [ { node: 'Filter URLs', type: 'main', index: 0 } ], [ { node: 'Error Response1', type: 'main', index: 0 } // CORRECT! ] ] } } }; const result2 = await validator.validateWorkflow(correctWorkflow); const hasIncorrectError = result2.errors.some(e => e.message.includes('Incorrect error output configuration') ); if (!hasIncorrectError) { console.log('✅ No error output configuration issues (correct!)'); } else { console.log('❌ Unexpected error found'); } // Test 3: onError without error connections console.log('\n📝 Test 3: onError without error connections'); console.log('-'.repeat(40)); const mismatchWorkflow = { nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [100, 100] as [number, number], parameters: {}, onError: 'continueErrorOutput' as const }, { id: '2', name: 'Process Data', type: 'n8n-nodes-base.set', typeVersion: 2, position: [300, 100] as [number, number], parameters: {} } ], connections: { 'HTTP Request': { main: [ [ { node: 'Process Data', type: 'main', index: 0 } ] // No main[1] for error output ] } } }; const result3 = await validator.validateWorkflow(mismatchWorkflow); const mismatchError = result3.errors.find(e => e.message.includes("has onError: 'continueErrorOutput' but no error output connections") ); if (mismatchError) { console.log('❌ ERROR DETECTED (as expected):'); console.log(`Node: ${mismatchError.nodeName}`); console.log(`Message: ${mismatchError.message}`); } else { console.log('✅ No mismatch detected (but should have!)'); } // Test 4: Error connections without onError console.log('\n📝 Test 4: Error connections without onError property'); console.log('-'.repeat(40)); const missingOnErrorWorkflow = { nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [100, 100] as [number, number], parameters: {} // Missing onError property }, { id: '2', name: 'Process Data', type: 'n8n-nodes-base.set', position: [300, 100] as [number, number], parameters: {} }, { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 300] as [number, number], parameters: {} } ], connections: { 'HTTP Request': { main: [ [ { node: 'Process Data', type: 'main', index: 0 } ], [ { node: 'Error Handler', type: 'main', index: 0 } ] ] } } }; const result4 = await validator.validateWorkflow(missingOnErrorWorkflow); const missingOnErrorWarning = result4.warnings.find(w => w.message.includes('error output connections in main[1] but missing onError') ); if (missingOnErrorWarning) { console.log('⚠️ WARNING DETECTED (as expected):'); console.log(`Node: ${missingOnErrorWarning.nodeName}`); console.log(`Message: ${missingOnErrorWarning.message}`); } else { console.log('✅ No warning (but should have warned!)'); } console.log('\n' + '='.repeat(60)); console.log('\n📊 Summary:'); console.log('- Error output validation is working correctly'); console.log('- Detects incorrect configurations (multiple nodes in main[0])'); console.log('- Validates onError property matches connections'); console.log('- Provides clear error messages with fix examples'); // Close database adapter.close(); } runTests().catch(error => { console.error('Test failed:', error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/utils/factories.ts: -------------------------------------------------------------------------------- ```typescript /** * Test Data Factories * * Provides factory functions for generating test data dynamically. * Useful for creating variations of workflows, nodes, and parameters. */ import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api'; import { createTestWorkflowName } from './test-context'; /** * Create a webhook node with custom parameters * * @param options - Node options * @returns WorkflowNode */ export function createWebhookNode(options: { id?: string; name?: string; httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path?: string; position?: [number, number]; responseMode?: 'onReceived' | 'lastNode'; }): WorkflowNode { return { id: options.id || `webhook-${Date.now()}`, name: options.name || 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: options.position || [250, 300], parameters: { httpMethod: options.httpMethod || 'GET', path: options.path || `test-${Date.now()}`, responseMode: options.responseMode || 'lastNode' } }; } /** * Create an HTTP Request node with custom parameters * * @param options - Node options * @returns WorkflowNode */ export function createHttpRequestNode(options: { id?: string; name?: string; url?: string; method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; position?: [number, number]; authentication?: string; }): WorkflowNode { return { id: options.id || `http-${Date.now()}`, name: options.name || 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4.2, position: options.position || [450, 300], parameters: { url: options.url || 'https://httpbin.org/get', method: options.method || 'GET', authentication: options.authentication || 'none' } }; } /** * Create a Set node with custom assignments * * @param options - Node options * @returns WorkflowNode */ export function createSetNode(options: { id?: string; name?: string; position?: [number, number]; assignments?: Array<{ name: string; value: any; type?: 'string' | 'number' | 'boolean' | 'object' | 'array'; }>; }): WorkflowNode { const assignments = options.assignments || [ { name: 'key', value: 'value', type: 'string' as const } ]; return { id: options.id || `set-${Date.now()}`, name: options.name || 'Set', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: options.position || [450, 300], parameters: { assignments: { assignments: assignments.map((a, idx) => ({ id: `assign-${idx}`, name: a.name, value: a.value, type: a.type || 'string' })) }, options: {} } }; } /** * Create a Manual Trigger node * * @param options - Node options * @returns WorkflowNode */ export function createManualTriggerNode(options: { id?: string; name?: string; position?: [number, number]; } = {}): WorkflowNode { return { id: options.id || `manual-${Date.now()}`, name: options.name || 'When clicking "Test workflow"', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: options.position || [250, 300], parameters: {} }; } /** * Create a simple connection between two nodes * * @param from - Source node name * @param to - Target node name * @param options - Connection options * @returns Connection object */ export function createConnection( from: string, to: string, options: { sourceOutput?: string; targetInput?: string; sourceIndex?: number; targetIndex?: number; } = {} ): Record<string, any> { const sourceOutput = options.sourceOutput || 'main'; const targetInput = options.targetInput || 'main'; const sourceIndex = options.sourceIndex || 0; const targetIndex = options.targetIndex || 0; return { [from]: { [sourceOutput]: [ [ { node: to, type: targetInput, index: targetIndex } ] ] } }; } /** * Create a workflow from nodes with automatic connections * * Connects nodes in sequence: node1 -> node2 -> node3, etc. * * @param name - Workflow name * @param nodes - Array of nodes * @returns Partial workflow */ export function createSequentialWorkflow( name: string, nodes: WorkflowNode[] ): Partial<Workflow> { const connections: Record<string, any> = {}; // Create connections between sequential nodes for (let i = 0; i < nodes.length - 1; i++) { const currentNode = nodes[i]; const nextNode = nodes[i + 1]; connections[currentNode.name] = { main: [[{ node: nextNode.name, type: 'main', index: 0 }]] }; } return { name: createTestWorkflowName(name), nodes, connections, settings: { executionOrder: 'v1' } }; } /** * Create a workflow with parallel branches * * Creates a workflow with one trigger node that splits into multiple * parallel execution paths. * * @param name - Workflow name * @param trigger - Trigger node * @param branches - Array of branch nodes * @returns Partial workflow */ export function createParallelWorkflow( name: string, trigger: WorkflowNode, branches: WorkflowNode[] ): Partial<Workflow> { const connections: Record<string, any> = { [trigger.name]: { main: [branches.map(node => ({ node: node.name, type: 'main', index: 0 }))] } }; return { name: createTestWorkflowName(name), nodes: [trigger, ...branches], connections, settings: { executionOrder: 'v1' } }; } /** * Generate a random string for test data * * @param length - String length (default: 8) * @returns Random string */ export function randomString(length: number = 8): string { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } /** * Generate a unique ID for testing * * @param prefix - Optional prefix * @returns Unique ID */ export function uniqueId(prefix: string = 'test'): string { return `${prefix}-${Date.now()}-${randomString(4)}`; } /** * Create a workflow with error handling * * @param name - Workflow name * @param mainNode - Main processing node * @param errorNode - Error handling node * @returns Partial workflow with error handling configured */ export function createErrorHandlingWorkflow( name: string, mainNode: WorkflowNode, errorNode: WorkflowNode ): Partial<Workflow> { const trigger = createWebhookNode({ name: 'Trigger', position: [250, 300] }); // Configure main node for error handling const mainNodeWithError = { ...mainNode, continueOnFail: true, onError: 'continueErrorOutput' as const }; const connections: Record<string, any> = { [trigger.name]: { main: [[{ node: mainNode.name, type: 'main', index: 0 }]] }, [mainNode.name]: { error: [[{ node: errorNode.name, type: 'main', index: 0 }]] } }; return { name: createTestWorkflowName(name), nodes: [trigger, mainNodeWithError, errorNode], connections, settings: { executionOrder: 'v1' } }; } /** * Create test workflow tags * * @param additional - Additional tags to include * @returns Array of tags for test workflows */ export function createTestTags(additional: string[] = []): string[] { return ['mcp-integration-test', ...additional]; } /** * Create workflow settings with common test configurations * * @param overrides - Settings to override * @returns Workflow settings object */ export function createWorkflowSettings(overrides: Record<string, any> = {}): Record<string, any> { return { executionOrder: 'v1', saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all', saveManualExecutions: true, ...overrides }; } ``` -------------------------------------------------------------------------------- /tests/http-server-auth.test.ts: -------------------------------------------------------------------------------- ```typescript import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { MockedFunction } from 'vitest'; // Import the actual functions we'll be testing import { loadAuthToken, startFixedHTTPServer } from '../src/http-server'; // Mock dependencies vi.mock('../src/utils/logger', () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, Logger: vi.fn().mockImplementation(() => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() })), LogLevel: { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3 } })); vi.mock('dotenv'); // Mock other dependencies to prevent side effects vi.mock('../src/mcp/server', () => ({ N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({ executeTool: vi.fn() })) })); vi.mock('../src/mcp/tools', () => ({ n8nDocumentationToolsFinal: [] })); vi.mock('../src/mcp/tools-n8n-manager', () => ({ n8nManagementTools: [] })); vi.mock('../src/utils/version', () => ({ PROJECT_VERSION: '2.7.4' })); vi.mock('../src/config/n8n-api', () => ({ isN8nApiConfigured: vi.fn().mockReturnValue(false) })); vi.mock('../src/utils/url-detector', () => ({ getStartupBaseUrl: vi.fn().mockReturnValue('http://localhost:3000'), formatEndpointUrls: vi.fn().mockReturnValue({ health: 'http://localhost:3000/health', mcp: 'http://localhost:3000/mcp' }), detectBaseUrl: vi.fn().mockReturnValue('http://localhost:3000') })); // Create mock server instance const mockServer = { on: vi.fn(), close: vi.fn((callback) => callback()) }; // Mock Express to prevent server from starting const mockExpressApp = { use: vi.fn(), get: vi.fn(), post: vi.fn(), listen: vi.fn((port: any, host: any, callback: any) => { // Call the callback immediately to simulate server start if (callback) callback(); return mockServer; }), set: vi.fn() }; vi.mock('express', () => { const express: any = vi.fn(() => mockExpressApp); express.json = vi.fn(); express.urlencoded = vi.fn(); express.static = vi.fn(); express.Request = {}; express.Response = {}; express.NextFunction = {}; return { default: express }; }); describe('HTTP Server Authentication', () => { const originalEnv = process.env; let tempDir: string; let authTokenFile: string; beforeEach(() => { // Reset modules and environment vi.clearAllMocks(); vi.resetModules(); process.env = { ...originalEnv }; // Create temporary directory for test files tempDir = join(tmpdir(), `http-server-auth-test-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); authTokenFile = join(tempDir, 'auth-token'); }); afterEach(() => { // Restore original environment process.env = originalEnv; // Clean up temporary directory try { rmSync(tempDir, { recursive: true, force: true }); } catch (error) { // Ignore cleanup errors } }); describe('loadAuthToken', () => { it('should load token when AUTH_TOKEN environment variable is set', () => { process.env.AUTH_TOKEN = 'test-token-from-env'; delete process.env.AUTH_TOKEN_FILE; const token = loadAuthToken(); expect(token).toBe('test-token-from-env'); }); it('should load token from file when only AUTH_TOKEN_FILE is set', () => { delete process.env.AUTH_TOKEN; process.env.AUTH_TOKEN_FILE = authTokenFile; // Write test token to file writeFileSync(authTokenFile, 'test-token-from-file\n'); const token = loadAuthToken(); expect(token).toBe('test-token-from-file'); }); it('should trim whitespace when reading token from file', () => { delete process.env.AUTH_TOKEN; process.env.AUTH_TOKEN_FILE = authTokenFile; // Write token with whitespace writeFileSync(authTokenFile, ' test-token-with-spaces \n\n'); const token = loadAuthToken(); expect(token).toBe('test-token-with-spaces'); }); it('should prefer AUTH_TOKEN when both variables are set', () => { process.env.AUTH_TOKEN = 'env-token'; process.env.AUTH_TOKEN_FILE = authTokenFile; writeFileSync(authTokenFile, 'file-token'); const token = loadAuthToken(); expect(token).toBe('env-token'); }); it('should return null when AUTH_TOKEN_FILE points to non-existent file', async () => { delete process.env.AUTH_TOKEN; process.env.AUTH_TOKEN_FILE = join(tempDir, 'non-existent-file'); // Import logger to check calls const { logger } = await import('../src/utils/logger'); // Clear any previous mock calls vi.clearAllMocks(); const token = loadAuthToken(); expect(token).toBeNull(); expect(logger.error).toHaveBeenCalled(); const errorCall = (logger.error as MockedFunction<any>).mock.calls[0]; expect(errorCall[0]).toContain('Failed to read AUTH_TOKEN_FILE'); // Check that the second argument exists and is truthy (the error object) expect(errorCall[1]).toBeTruthy(); }); it('should return null when no auth variables are set', () => { delete process.env.AUTH_TOKEN; delete process.env.AUTH_TOKEN_FILE; const token = loadAuthToken(); expect(token).toBeNull(); }); }); describe('validateEnvironment', () => { it('should exit process when no auth token is available', async () => { delete process.env.AUTH_TOKEN; delete process.env.AUTH_TOKEN_FILE; const mockExit = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { throw new Error('Process exited'); }); // validateEnvironment is called when starting the server await expect(async () => { await startFixedHTTPServer(); }).rejects.toThrow('Process exited'); expect(mockExit).toHaveBeenCalledWith(1); mockExit.mockRestore(); }); it('should warn when token length is less than 32 characters', async () => { process.env.AUTH_TOKEN = 'short-token'; // Import logger to check calls const { logger } = await import('../src/utils/logger'); // Clear any previous mock calls vi.clearAllMocks(); // Ensure the mock server is properly configured mockExpressApp.listen.mockReturnValue(mockServer); mockServer.on.mockReturnValue(undefined); // Start the server which will trigger validateEnvironment await startFixedHTTPServer(); expect(logger.warn).toHaveBeenCalledWith( 'AUTH_TOKEN should be at least 32 characters for security' ); }); }); describe('Integration test scenarios', () => { it('should authenticate successfully when token is loaded from file', () => { // This is more of an integration test placeholder // In a real scenario, you'd start the server and make HTTP requests writeFileSync(authTokenFile, 'very-secure-token-with-more-than-32-characters'); process.env.AUTH_TOKEN_FILE = authTokenFile; delete process.env.AUTH_TOKEN; const token = loadAuthToken(); expect(token).toBe('very-secure-token-with-more-than-32-characters'); }); it('should load token when using Docker secrets pattern', () => { // Docker secrets are typically mounted at /run/secrets/ const dockerSecretPath = join(tempDir, 'run', 'secrets', 'auth_token'); mkdirSync(join(tempDir, 'run', 'secrets'), { recursive: true }); writeFileSync(dockerSecretPath, 'docker-secret-token'); process.env.AUTH_TOKEN_FILE = dockerSecretPath; delete process.env.AUTH_TOKEN; const token = loadAuthToken(); expect(token).toBe('docker-secret-token'); }); }); }); ``` -------------------------------------------------------------------------------- /src/n8n/MCPNode.node.ts: -------------------------------------------------------------------------------- ```typescript import { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription, NodeOperationError, } from 'n8n-workflow'; import { MCPClient } from '../utils/mcp-client'; import { N8NMCPBridge } from '../utils/bridge'; export class MCPNode implements INodeType { description: INodeTypeDescription = { displayName: 'MCP', name: 'mcp', icon: 'file:mcp.svg', group: ['transform'], version: 1, description: 'Interact with Model Context Protocol (MCP) servers', defaults: { name: 'MCP', }, inputs: ['main'], outputs: ['main'], credentials: [ { name: 'mcpApi', required: true, }, ], properties: [ { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, options: [ { name: 'Call Tool', value: 'callTool', description: 'Execute an MCP tool', }, { name: 'List Tools', value: 'listTools', description: 'List available MCP tools', }, { name: 'Read Resource', value: 'readResource', description: 'Read an MCP resource', }, { name: 'List Resources', value: 'listResources', description: 'List available MCP resources', }, { name: 'Get Prompt', value: 'getPrompt', description: 'Get an MCP prompt', }, { name: 'List Prompts', value: 'listPrompts', description: 'List available MCP prompts', }, ], default: 'callTool', }, // Tool-specific fields { displayName: 'Tool Name', name: 'toolName', type: 'string', required: true, displayOptions: { show: { operation: ['callTool'], }, }, default: '', description: 'Name of the MCP tool to execute', }, { displayName: 'Tool Arguments', name: 'toolArguments', type: 'json', required: false, displayOptions: { show: { operation: ['callTool'], }, }, default: '{}', description: 'Arguments to pass to the MCP tool', }, // Resource-specific fields { displayName: 'Resource URI', name: 'resourceUri', type: 'string', required: true, displayOptions: { show: { operation: ['readResource'], }, }, default: '', description: 'URI of the MCP resource to read', }, // Prompt-specific fields { displayName: 'Prompt Name', name: 'promptName', type: 'string', required: true, displayOptions: { show: { operation: ['getPrompt'], }, }, default: '', description: 'Name of the MCP prompt to retrieve', }, { displayName: 'Prompt Arguments', name: 'promptArguments', type: 'json', required: false, displayOptions: { show: { operation: ['getPrompt'], }, }, default: '{}', description: 'Arguments to pass to the MCP prompt', }, ], }; async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const operation = this.getNodeParameter('operation', 0) as string; // Get credentials const credentials = await this.getCredentials('mcpApi'); for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { try { let result: any; switch (operation) { case 'callTool': const toolName = this.getNodeParameter('toolName', itemIndex) as string; const toolArgumentsJson = this.getNodeParameter('toolArguments', itemIndex) as string; const toolArguments = JSON.parse(toolArgumentsJson); result = await (this as any).callMCPTool(credentials, toolName, toolArguments); break; case 'listTools': result = await (this as any).listMCPTools(credentials); break; case 'readResource': const resourceUri = this.getNodeParameter('resourceUri', itemIndex) as string; result = await (this as any).readMCPResource(credentials, resourceUri); break; case 'listResources': result = await (this as any).listMCPResources(credentials); break; case 'getPrompt': const promptName = this.getNodeParameter('promptName', itemIndex) as string; const promptArgumentsJson = this.getNodeParameter('promptArguments', itemIndex) as string; const promptArguments = JSON.parse(promptArgumentsJson); result = await (this as any).getMCPPrompt(credentials, promptName, promptArguments); break; case 'listPrompts': result = await (this as any).listMCPPrompts(credentials); break; default: throw new NodeOperationError(this.getNode(), `Unknown operation: ${operation}`); } returnData.push({ json: result, pairedItem: itemIndex, }); } catch (error) { if (this.continueOnFail()) { returnData.push({ json: { error: error instanceof Error ? error.message : 'Unknown error', }, pairedItem: itemIndex, }); continue; } throw error; } } return [returnData]; } // MCP client methods private async getMCPClient(credentials: any): Promise<MCPClient> { const client = new MCPClient({ serverUrl: credentials.serverUrl, authToken: credentials.authToken, connectionType: credentials.connectionType || 'websocket', }); await client.connect(); return client; } private async callMCPTool(credentials: any, toolName: string, args: any): Promise<any> { const client = await this.getMCPClient(credentials); try { const result = await client.callTool(toolName, args); return N8NMCPBridge.mcpToN8NExecutionData(result).json; } finally { await client.disconnect(); } } private async listMCPTools(credentials: any): Promise<any> { const client = await this.getMCPClient(credentials); try { return await client.listTools(); } finally { await client.disconnect(); } } private async readMCPResource(credentials: any, uri: string): Promise<any> { const client = await this.getMCPClient(credentials); try { const result = await client.readResource(uri); return N8NMCPBridge.mcpToN8NExecutionData(result).json; } finally { await client.disconnect(); } } private async listMCPResources(credentials: any): Promise<any> { const client = await this.getMCPClient(credentials); try { return await client.listResources(); } finally { await client.disconnect(); } } private async getMCPPrompt(credentials: any, promptName: string, args: any): Promise<any> { const client = await this.getMCPClient(credentials); try { const result = await client.getPrompt(promptName, args); return N8NMCPBridge.mcpPromptArgsToN8N(result); } finally { await client.disconnect(); } } private async listMCPPrompts(credentials: any): Promise<any> { const client = await this.getMCPClient(credentials); try { return await client.listPrompts(); } finally { await client.disconnect(); } } } ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/utils/fixtures.ts: -------------------------------------------------------------------------------- ```typescript /** * Workflow Fixtures for Integration Tests * * Provides reusable workflow templates for testing. * All fixtures use FULL node type format (n8n-nodes-base.*) * as required by the n8n API. */ import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api'; /** * Simple webhook workflow with a single Webhook node * * Use this for basic workflow creation tests. */ export const SIMPLE_WEBHOOK_WORKFLOW: Partial<Workflow> = { nodes: [ { id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300], parameters: { httpMethod: 'GET', path: 'test-webhook' } } ], connections: {}, settings: { executionOrder: 'v1' } }; /** * Simple HTTP request workflow * * Contains a Webhook trigger and an HTTP Request node. * Tests basic workflow connections. */ export const SIMPLE_HTTP_WORKFLOW: Partial<Workflow> = { nodes: [ { id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300], parameters: { httpMethod: 'GET', path: 'trigger' } }, { id: 'http-1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4.2, position: [450, 300], parameters: { url: 'https://httpbin.org/get', method: 'GET' } } ], connections: { Webhook: { main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] } }, settings: { executionOrder: 'v1' } }; /** * Multi-node workflow with branching * * Tests complex connections and multiple execution paths. */ export const MULTI_NODE_WORKFLOW: Partial<Workflow> = { nodes: [ { id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300], parameters: { httpMethod: 'POST', path: 'multi-node' } }, { id: 'set-1', name: 'Set 1', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [450, 200], parameters: { assignments: { assignments: [ { id: 'assign-1', name: 'branch', value: 'top', type: 'string' } ] }, options: {} } }, { id: 'set-2', name: 'Set 2', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [450, 400], parameters: { assignments: { assignments: [ { id: 'assign-2', name: 'branch', value: 'bottom', type: 'string' } ] }, options: {} } }, { id: 'merge-1', name: 'Merge', type: 'n8n-nodes-base.merge', typeVersion: 3, position: [650, 300], parameters: { mode: 'append', options: {} } } ], connections: { Webhook: { main: [ [ { node: 'Set 1', type: 'main', index: 0 }, { node: 'Set 2', type: 'main', index: 0 } ] ] }, 'Set 1': { main: [[{ node: 'Merge', type: 'main', index: 0 }]] }, 'Set 2': { main: [[{ node: 'Merge', type: 'main', index: 1 }]] } }, settings: { executionOrder: 'v1' } }; /** * Workflow with error handling * * Tests error output configuration and error workflows. */ export const ERROR_HANDLING_WORKFLOW: Partial<Workflow> = { nodes: [ { id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300], parameters: { httpMethod: 'GET', path: 'error-test' } }, { id: 'http-1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4.2, position: [450, 300], parameters: { url: 'https://httpbin.org/status/500', method: 'GET' }, continueOnFail: true, onError: 'continueErrorOutput' }, { id: 'set-error', name: 'Handle Error', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [650, 400], parameters: { assignments: { assignments: [ { id: 'error-assign', name: 'error_handled', value: 'true', type: 'boolean' } ] }, options: {} } } ], connections: { Webhook: { main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] }, 'HTTP Request': { main: [[{ node: 'Handle Error', type: 'main', index: 0 }]], error: [[{ node: 'Handle Error', type: 'main', index: 0 }]] } }, settings: { executionOrder: 'v1' } }; /** * AI Agent workflow (langchain nodes) * * Tests langchain node support. */ export const AI_AGENT_WORKFLOW: Partial<Workflow> = { nodes: [ { id: 'manual-1', name: 'When clicking "Test workflow"', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [250, 300], parameters: {} }, { id: 'agent-1', name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: { promptType: 'define', text: '={{ $json.input }}', options: {} } } ], connections: { 'When clicking "Test workflow"': { main: [[{ node: 'AI Agent', type: 'main', index: 0 }]] } }, settings: { executionOrder: 'v1' } }; /** * Workflow with n8n expressions * * Tests expression validation. */ export const EXPRESSION_WORKFLOW: Partial<Workflow> = { nodes: [ { id: 'manual-1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [250, 300], parameters: {} }, { id: 'set-1', name: 'Set Variables', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [450, 300], parameters: { assignments: { assignments: [ { id: 'expr-1', name: 'timestamp', value: '={{ $now }}', type: 'string' }, { id: 'expr-2', name: 'item_count', value: '={{ $json.items.length }}', type: 'number' }, { id: 'expr-3', name: 'first_item', value: '={{ $node["Manual Trigger"].json }}', type: 'object' } ] }, options: {} } } ], connections: { 'Manual Trigger': { main: [[{ node: 'Set Variables', type: 'main', index: 0 }]] } }, settings: { executionOrder: 'v1' } }; /** * Get a fixture by name * * @param name - Fixture name * @returns Workflow fixture */ export function getFixture( name: | 'simple-webhook' | 'simple-http' | 'multi-node' | 'error-handling' | 'ai-agent' | 'expression' ): Partial<Workflow> { const fixtures = { 'simple-webhook': SIMPLE_WEBHOOK_WORKFLOW, 'simple-http': SIMPLE_HTTP_WORKFLOW, 'multi-node': MULTI_NODE_WORKFLOW, 'error-handling': ERROR_HANDLING_WORKFLOW, 'ai-agent': AI_AGENT_WORKFLOW, expression: EXPRESSION_WORKFLOW }; return JSON.parse(JSON.stringify(fixtures[name])); // Deep clone } /** * Create a minimal workflow with custom nodes * * @param nodes - Array of workflow nodes * @param connections - Optional connections object * @returns Workflow fixture */ export function createCustomWorkflow( nodes: WorkflowNode[], connections: Record<string, any> = {} ): Partial<Workflow> { return { nodes, connections, settings: { executionOrder: 'v1' } }; } ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/system/list-tools.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Tests: handleListAvailableTools * * Tests tool listing functionality. * Covers tool discovery and configuration status. */ import { describe, it, expect, beforeEach } from 'vitest'; import { createMcpContext } from '../utils/mcp-context'; import { InstanceContext } from '../../../../src/types/instance-context'; import { handleListAvailableTools } from '../../../../src/mcp/handlers-n8n-manager'; import { ListToolsResponse } from '../utils/response-types'; describe('Integration: handleListAvailableTools', () => { let mcpContext: InstanceContext; beforeEach(() => { mcpContext = createMcpContext(); }); // ====================================================================== // List All Tools // ====================================================================== describe('Tool Listing', () => { it('should list all available tools organized by category', async () => { const response = await handleListAvailableTools(mcpContext); expect(response.success).toBe(true); expect(response.data).toBeDefined(); const data = response.data as ListToolsResponse; // Verify tools array exists expect(data).toHaveProperty('tools'); expect(Array.isArray(data.tools)).toBe(true); expect(data.tools.length).toBeGreaterThan(0); // Verify tool categories const categories = data.tools.map((cat: any) => cat.category); expect(categories).toContain('Workflow Management'); expect(categories).toContain('Execution Management'); expect(categories).toContain('System'); // Verify each category has tools data.tools.forEach(category => { expect(category).toHaveProperty('category'); expect(category).toHaveProperty('tools'); expect(Array.isArray(category.tools)).toBe(true); expect(category.tools.length).toBeGreaterThan(0); // Verify each tool has required fields category.tools.forEach(tool => { expect(tool).toHaveProperty('name'); expect(tool).toHaveProperty('description'); expect(typeof tool.name).toBe('string'); expect(typeof tool.description).toBe('string'); }); }); }); it('should include API configuration status', async () => { const response = await handleListAvailableTools(mcpContext); expect(response.success).toBe(true); const data = response.data as ListToolsResponse; // Verify configuration status expect(data).toHaveProperty('apiConfigured'); expect(typeof data.apiConfigured).toBe('boolean'); // Since tests run with API configured, should be true expect(data.apiConfigured).toBe(true); // Verify configuration details are present when configured if (data.apiConfigured) { expect(data).toHaveProperty('configuration'); expect(data.configuration).toBeDefined(); expect(data.configuration).toHaveProperty('apiUrl'); expect(data.configuration).toHaveProperty('timeout'); expect(data.configuration).toHaveProperty('maxRetries'); } }); it('should include API limitations information', async () => { const response = await handleListAvailableTools(mcpContext); expect(response.success).toBe(true); const data = response.data as ListToolsResponse; // Verify limitations are documented expect(data).toHaveProperty('limitations'); expect(Array.isArray(data.limitations)).toBe(true); expect(data.limitations.length).toBeGreaterThan(0); // Verify limitations are informative strings data.limitations.forEach(limitation => { expect(typeof limitation).toBe('string'); expect(limitation.length).toBeGreaterThan(0); }); // Common known limitations const limitationsText = data.limitations.join(' '); expect(limitationsText).toContain('Cannot activate'); expect(limitationsText).toContain('Cannot execute workflows directly'); }); }); // ====================================================================== // Workflow Management Tools // ====================================================================== describe('Workflow Management Tools', () => { it('should include all workflow management tools', async () => { const response = await handleListAvailableTools(mcpContext); const data = response.data as ListToolsResponse; const workflowCategory = data.tools.find(cat => cat.category === 'Workflow Management'); expect(workflowCategory).toBeDefined(); const toolNames = workflowCategory!.tools.map(t => t.name); // Core workflow tools expect(toolNames).toContain('n8n_create_workflow'); expect(toolNames).toContain('n8n_get_workflow'); expect(toolNames).toContain('n8n_update_workflow'); expect(toolNames).toContain('n8n_delete_workflow'); expect(toolNames).toContain('n8n_list_workflows'); // Enhanced workflow tools expect(toolNames).toContain('n8n_get_workflow_details'); expect(toolNames).toContain('n8n_get_workflow_structure'); expect(toolNames).toContain('n8n_get_workflow_minimal'); expect(toolNames).toContain('n8n_validate_workflow'); expect(toolNames).toContain('n8n_autofix_workflow'); }); }); // ====================================================================== // Execution Management Tools // ====================================================================== describe('Execution Management Tools', () => { it('should include all execution management tools', async () => { const response = await handleListAvailableTools(mcpContext); const data = response.data as ListToolsResponse; const executionCategory = data.tools.find(cat => cat.category === 'Execution Management'); expect(executionCategory).toBeDefined(); const toolNames = executionCategory!.tools.map(t => t.name); expect(toolNames).toContain('n8n_trigger_webhook_workflow'); expect(toolNames).toContain('n8n_get_execution'); expect(toolNames).toContain('n8n_list_executions'); expect(toolNames).toContain('n8n_delete_execution'); }); }); // ====================================================================== // System Tools // ====================================================================== describe('System Tools', () => { it('should include system tools', async () => { const response = await handleListAvailableTools(mcpContext); const data = response.data as ListToolsResponse; const systemCategory = data.tools.find(cat => cat.category === 'System'); expect(systemCategory).toBeDefined(); const toolNames = systemCategory!.tools.map(t => t.name); expect(toolNames).toContain('n8n_health_check'); expect(toolNames).toContain('n8n_list_available_tools'); }); }); // ====================================================================== // Response Format Verification // ====================================================================== describe('Response Format', () => { it('should return complete tool list response structure', async () => { const response = await handleListAvailableTools(mcpContext); expect(response.success).toBe(true); expect(response.data).toBeDefined(); const data = response.data as ListToolsResponse; // Verify all required fields expect(data).toHaveProperty('tools'); expect(data).toHaveProperty('apiConfigured'); expect(data).toHaveProperty('limitations'); // Verify optional configuration field if (data.apiConfigured) { expect(data).toHaveProperty('configuration'); } // Verify data types expect(Array.isArray(data.tools)).toBe(true); expect(typeof data.apiConfigured).toBe('boolean'); expect(Array.isArray(data.limitations)).toBe(true); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/resource-similarity-service.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for ResourceSimilarityService */ import { describe, it, expect, beforeEach } from 'vitest'; import { ResourceSimilarityService } from '../../../src/services/resource-similarity-service'; import { NodeRepository } from '../../../src/database/node-repository'; import { createTestDatabase } from '../../utils/database-utils'; describe('ResourceSimilarityService', () => { let service: ResourceSimilarityService; let repository: NodeRepository; let testDb: any; beforeEach(async () => { testDb = await createTestDatabase(); repository = testDb.nodeRepository; service = new ResourceSimilarityService(repository); // Add test node with resources const testNode = { nodeType: 'nodes-base.googleDrive', packageName: 'n8n-nodes-base', displayName: 'Google Drive', description: 'Access Google Drive', category: 'transform', style: 'declarative' as const, isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1', properties: [ { name: 'resource', type: 'options', options: [ { value: 'file', name: 'File' }, { value: 'folder', name: 'Folder' }, { value: 'drive', name: 'Shared Drive' }, { value: 'fileFolder', name: 'File & Folder' } ] } ], operations: [], credentials: [] }; repository.saveNode(testNode); // Add Slack node for testing different patterns const slackNode = { nodeType: 'nodes-base.slack', packageName: 'n8n-nodes-base', displayName: 'Slack', description: 'Send messages to Slack', category: 'communication', style: 'declarative' as const, isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '2', properties: [ { name: 'resource', type: 'options', options: [ { value: 'channel', name: 'Channel' }, { value: 'message', name: 'Message' }, { value: 'user', name: 'User' }, { value: 'file', name: 'File' }, { value: 'star', name: 'Star' } ] } ], operations: [], credentials: [] }; repository.saveNode(slackNode); }); afterEach(async () => { if (testDb) { await testDb.cleanup(); } }); describe('findSimilarResources', () => { it('should find exact match', () => { const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'file', 5 ); expect(suggestions).toHaveLength(0); // No suggestions for valid resource }); it('should suggest singular form for plural input', () => { const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'files', 5 ); expect(suggestions.length).toBeGreaterThan(0); expect(suggestions[0].value).toBe('file'); expect(suggestions[0].confidence).toBeGreaterThanOrEqual(0.9); expect(suggestions[0].reason).toContain('singular'); }); it('should suggest singular form for folders', () => { const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'folders', 5 ); expect(suggestions.length).toBeGreaterThan(0); expect(suggestions[0].value).toBe('folder'); expect(suggestions[0].confidence).toBeGreaterThanOrEqual(0.9); }); it('should handle typos with Levenshtein distance', () => { const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'flie', 5 ); expect(suggestions.length).toBeGreaterThan(0); expect(suggestions[0].value).toBe('file'); expect(suggestions[0].confidence).toBeGreaterThan(0.7); }); it('should handle combined resources', () => { const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'fileAndFolder', 5 ); expect(suggestions.length).toBeGreaterThan(0); // Should suggest 'fileFolder' (the actual combined resource) const fileFolderSuggestion = suggestions.find(s => s.value === 'fileFolder'); expect(fileFolderSuggestion).toBeDefined(); }); it('should return empty array for node not found', () => { const suggestions = service.findSimilarResources( 'nodes-base.nonexistent', 'resource', 5 ); expect(suggestions).toEqual([]); }); }); describe('plural/singular detection', () => { it('should handle regular plurals (s)', () => { const suggestions = service.findSimilarResources( 'nodes-base.slack', 'channels', 5 ); expect(suggestions.length).toBeGreaterThan(0); expect(suggestions[0].value).toBe('channel'); }); it('should handle plural ending in es', () => { const suggestions = service.findSimilarResources( 'nodes-base.slack', 'messages', 5 ); expect(suggestions.length).toBeGreaterThan(0); expect(suggestions[0].value).toBe('message'); }); it('should handle plural ending in ies', () => { // Test with a hypothetical 'entities' -> 'entity' conversion const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'entities', 5 ); // Should not crash and provide some suggestions expect(suggestions).toBeDefined(); }); }); describe('node-specific patterns', () => { it('should apply Google Drive specific patterns', () => { const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'sharedDrives', 5 ); expect(suggestions.length).toBeGreaterThan(0); const driveSuggestion = suggestions.find(s => s.value === 'drive'); expect(driveSuggestion).toBeDefined(); }); it('should apply Slack specific patterns', () => { const suggestions = service.findSimilarResources( 'nodes-base.slack', 'users', 5 ); expect(suggestions.length).toBeGreaterThan(0); expect(suggestions[0].value).toBe('user'); }); }); describe('similarity calculation', () => { it('should rank exact matches highest', () => { const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'file', 5 ); expect(suggestions).toHaveLength(0); // Exact match, no suggestions }); it('should rank substring matches high', () => { const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'fil', 5 ); expect(suggestions.length).toBeGreaterThan(0); const fileSuggestion = suggestions.find(s => s.value === 'file'); expect(fileSuggestion).toBeDefined(); expect(fileSuggestion!.confidence).toBeGreaterThanOrEqual(0.7); }); }); describe('caching', () => { it('should cache results for repeated queries', () => { // First call const suggestions1 = service.findSimilarResources( 'nodes-base.googleDrive', 'files', 5 ); // Second call with same params const suggestions2 = service.findSimilarResources( 'nodes-base.googleDrive', 'files', 5 ); expect(suggestions1).toEqual(suggestions2); }); it('should clear cache when requested', () => { // Add to cache service.findSimilarResources( 'nodes-base.googleDrive', 'test', 5 ); // Clear cache service.clearCache(); // This would fetch fresh data (behavior is the same, just uncached) const suggestions = service.findSimilarResources( 'nodes-base.googleDrive', 'test', 5 ); expect(suggestions).toBeDefined(); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/__mocks__/n8n-nodes-base.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { getNodeTypes, mockNodeBehavior, resetAllMocks, registerMockNode } from './n8n-nodes-base'; describe('n8n-nodes-base mock', () => { beforeEach(() => { resetAllMocks(); }); describe('getNodeTypes', () => { it('should return node types registry', () => { const registry = getNodeTypes(); expect(registry).toBeDefined(); expect(registry.getByName).toBeDefined(); expect(registry.getByNameAndVersion).toBeDefined(); }); it('should retrieve webhook node', () => { const registry = getNodeTypes(); const webhookNode = registry.getByName('webhook'); expect(webhookNode).toBeDefined(); expect(webhookNode?.description.name).toBe('webhook'); expect(webhookNode?.description.group).toContain('trigger'); expect(webhookNode?.webhook).toBeDefined(); }); it('should retrieve httpRequest node', () => { const registry = getNodeTypes(); const httpNode = registry.getByName('httpRequest'); expect(httpNode).toBeDefined(); expect(httpNode?.description.name).toBe('httpRequest'); expect(httpNode?.description.version).toBe(3); expect(httpNode?.execute).toBeDefined(); }); it('should retrieve slack node', () => { const registry = getNodeTypes(); const slackNode = registry.getByName('slack'); expect(slackNode).toBeDefined(); expect(slackNode?.description.credentials).toHaveLength(1); expect(slackNode?.description.credentials?.[0].name).toBe('slackApi'); }); }); describe('node execution', () => { it('should execute webhook node', async () => { const registry = getNodeTypes(); const webhookNode = registry.getByName('webhook'); const mockContext = { getWebhookName: vi.fn(() => 'default'), getBodyData: vi.fn(() => ({ test: 'data' })), getHeaderData: vi.fn(() => ({ 'content-type': 'application/json' })), getQueryData: vi.fn(() => ({ query: 'param' })), getRequestObject: vi.fn(), getResponseObject: vi.fn(), helpers: { returnJsonArray: vi.fn((data) => [{ json: data }]), }, }; const result = await webhookNode?.webhook?.call(mockContext as any); expect(result).toBeDefined(); expect(result?.workflowData).toBeDefined(); expect(result?.workflowData[0]).toHaveLength(1); expect(result?.workflowData[0][0].json).toMatchObject({ headers: { 'content-type': 'application/json' }, params: { query: 'param' }, body: { test: 'data' }, }); }); it('should execute httpRequest node', async () => { const registry = getNodeTypes(); const httpNode = registry.getByName('httpRequest'); const mockContext = { getInputData: vi.fn(() => [{ json: { test: 'input' } }]), getNodeParameter: vi.fn((name: string) => { if (name === 'method') return 'POST'; if (name === 'url') return 'https://api.example.com'; return ''; }), getCredentials: vi.fn(), helpers: { returnJsonArray: vi.fn((data) => [{ json: data }]), httpRequest: vi.fn(), webhook: vi.fn(), }, }; const result = await httpNode?.execute?.call(mockContext as any); expect(result).toBeDefined(); expect(result!).toHaveLength(1); expect(result![0]).toHaveLength(1); expect(result![0][0].json).toMatchObject({ statusCode: 200, body: { success: true, method: 'POST', url: 'https://api.example.com', }, }); }); }); describe('mockNodeBehavior', () => { it('should override node execution behavior', async () => { const customExecute = vi.fn(async function() { return [[{ json: { custom: 'response' } }]]; }); mockNodeBehavior('httpRequest', { execute: customExecute, }); const registry = getNodeTypes(); const httpNode = registry.getByName('httpRequest'); const mockContext = { getInputData: vi.fn(() => []), getNodeParameter: vi.fn(), getCredentials: vi.fn(), helpers: { returnJsonArray: vi.fn(), httpRequest: vi.fn(), webhook: vi.fn(), }, }; const result = await httpNode?.execute?.call(mockContext as any); expect(customExecute).toHaveBeenCalled(); expect(result).toEqual([[{ json: { custom: 'response' } }]]); }); it('should override node description', () => { mockNodeBehavior('slack', { description: { displayName: 'Custom Slack', version: 3, name: 'slack', group: ['output'], description: 'Send messages to Slack', defaults: { name: 'Slack' }, inputs: ['main'], outputs: ['main'], properties: [], }, }); const registry = getNodeTypes(); const slackNode = registry.getByName('slack'); expect(slackNode?.description.displayName).toBe('Custom Slack'); expect(slackNode?.description.version).toBe(3); expect(slackNode?.description.name).toBe('slack'); // Original preserved }); }); describe('registerMockNode', () => { it('should register custom node', () => { const customNode = { description: { displayName: 'Custom Node', name: 'customNode', group: ['transform'], version: 1, description: 'A custom test node', defaults: { name: 'Custom' }, inputs: ['main'], outputs: ['main'], properties: [], }, execute: vi.fn(async function() { return [[{ json: { custom: true } }]]; }), }; registerMockNode('customNode', customNode); const registry = getNodeTypes(); const retrievedNode = registry.getByName('customNode'); expect(retrievedNode).toBe(customNode); expect(retrievedNode?.description.name).toBe('customNode'); }); }); describe('conditional nodes', () => { it('should execute if node with two outputs', async () => { const registry = getNodeTypes(); const ifNode = registry.getByName('if'); const mockContext = { getInputData: vi.fn(() => [ { json: { value: 1 } }, { json: { value: 2 } }, { json: { value: 3 } }, { json: { value: 4 } }, ]), getNodeParameter: vi.fn(), getCredentials: vi.fn(), helpers: { returnJsonArray: vi.fn(), httpRequest: vi.fn(), webhook: vi.fn(), }, }; const result = await ifNode?.execute?.call(mockContext as any); expect(result!).toHaveLength(2); // true and false outputs expect(result![0]).toHaveLength(2); // even indices expect(result![1]).toHaveLength(2); // odd indices }); it('should execute switch node with multiple outputs', async () => { const registry = getNodeTypes(); const switchNode = registry.getByName('switch'); const mockContext = { getInputData: vi.fn(() => [ { json: { value: 1 } }, { json: { value: 2 } }, { json: { value: 3 } }, { json: { value: 4 } }, ]), getNodeParameter: vi.fn(), getCredentials: vi.fn(), helpers: { returnJsonArray: vi.fn(), httpRequest: vi.fn(), webhook: vi.fn(), }, }; const result = await switchNode?.execute?.call(mockContext as any); expect(result!).toHaveLength(4); // 4 outputs expect(result![0]).toHaveLength(1); // item 0 expect(result![1]).toHaveLength(1); // item 1 expect(result![2]).toHaveLength(1); // item 2 expect(result![3]).toHaveLength(1); // item 3 }); }); }); ```