This is page 20 of 46. Use http://codebase.md/czlonkowski/n8n-mcp?lines=false&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-sanitizer.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── sqljs-memory-leak.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-sanitizer.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 -------------------------------------------------------------------------------- /tests/unit/services/config-validator-node-specific.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ConfigValidator } from '@/services/config-validator'; import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator'; // Mock the database vi.mock('better-sqlite3'); describe('ConfigValidator - Node-Specific Validation', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('HTTP Request node validation', () => { it('should perform HTTP Request specific validation', () => { const nodeType = 'nodes-base.httpRequest'; const config = { method: 'POST', url: 'invalid-url', // Missing protocol sendBody: false }; const properties = [ { name: 'method', type: 'options' }, { name: 'url', type: 'string' }, { name: 'sendBody', type: 'boolean' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toMatchObject({ type: 'invalid_value', property: 'url', message: 'URL must start with http:// or https://' }); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toMatchObject({ type: 'missing_common', property: 'sendBody', message: 'POST requests typically send a body' }); expect(result.autofix).toMatchObject({ sendBody: true, contentType: 'json' }); }); it('should validate HTTP Request with authentication in API URLs', () => { const nodeType = 'nodes-base.httpRequest'; const config = { method: 'GET', url: 'https://api.github.com/user/repos', authentication: 'none' }; const properties = [ { name: 'method', type: 'options' }, { name: 'url', type: 'string' }, { name: 'authentication', type: 'options' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'security' && w.message.includes('API endpoints typically require authentication') )).toBe(true); }); it('should validate JSON in HTTP Request body', () => { const nodeType = 'nodes-base.httpRequest'; const config = { method: 'POST', url: 'https://api.example.com', contentType: 'json', body: '{"invalid": json}' // Invalid JSON }; const properties = [ { name: 'method', type: 'options' }, { name: 'url', type: 'string' }, { name: 'contentType', type: 'options' }, { name: 'body', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.errors.some(e => e.property === 'body' && e.message.includes('Invalid JSON') )); }); it('should handle webhook-specific validation', () => { const nodeType = 'nodes-base.webhook'; const config = { httpMethod: 'GET', path: 'webhook-endpoint' // Missing leading slash }; const properties = [ { name: 'httpMethod', type: 'options' }, { name: 'path', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.property === 'path' && w.message.includes('should start with /') )); }); }); describe('Code node validation', () => { it('should validate Code node configurations', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: '' // Empty code }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toMatchObject({ type: 'missing_required', property: 'jsCode', message: 'Code cannot be empty' }); }); it('should validate JavaScript syntax in Code node', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` const data = { foo: "bar" }; if (data.foo { // Missing closing parenthesis return [{json: data}]; } ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.errors.some(e => e.message.includes('Unbalanced'))); expect(result.warnings).toHaveLength(1); }); it('should validate n8n-specific patterns in Code node', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` // Process data without returning const processedData = items.map(item => ({ ...item.json, processed: true })); // No output provided ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); // The warning should be about missing return statement expect(result.warnings.some(w => w.type === 'missing_common' && w.message.includes('No return statement found'))).toBe(true); }); it('should handle empty code in Code node', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ' \n \t \n ' // Just whitespace }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.valid).toBe(false); expect(result.errors.some(e => e.type === 'missing_required' && e.message.includes('Code cannot be empty') )).toBe(true); }); it('should validate complex return patterns in Code node', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` return ["string1", "string2", "string3"]; ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'invalid_value' && w.message.includes('Items must be objects with json property') )).toBe(true); }); it('should validate Code node with $helpers usage', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` const workflow = $helpers.getWorkflowStaticData(); workflow.counter = (workflow.counter || 0) + 1; return [{json: {count: workflow.counter}}]; ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('$helpers is only available in Code nodes') )).toBe(true); }); it('should detect incorrect $helpers.getWorkflowStaticData usage', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` const data = $helpers.getWorkflowStaticData; // Missing parentheses return [{json: {data}}]; ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.errors.some(e => e.type === 'invalid_value' && e.message.includes('getWorkflowStaticData requires parentheses') )).toBe(true); }); it('should validate console.log usage', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` console.log('Debug info:', items); return items; ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('console.log output appears in n8n execution logs') )).toBe(true); }); it('should validate $json usage warning', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` const data = $json.myField; return [{json: {processed: data}}]; ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('$json only works in "Run Once for Each Item" mode') )).toBe(true); }); it('should not warn about properties for Code nodes', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: 'return items;', unusedProperty: 'this should not generate a warning for Code nodes' }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); // Code nodes should skip the common issues check that warns about unused properties expect(result.warnings.some(w => w.type === 'inefficient' && w.property === 'unusedProperty' )).toBe(false); }); it('should validate crypto module usage', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` const uuid = crypto.randomUUID(); return [{json: {id: uuid}}]; ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'invalid_value' && w.message.includes('Using crypto without require') )).toBe(true); }); it('should suggest error handling for complex code', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` const apiUrl = items[0].json.url; const response = await fetch(apiUrl); const data = await response.json(); return [{json: data}]; ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.suggestions.some(s => s.includes('Consider adding error handling') )); }); it('should suggest error handling for non-trivial code', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: Array(10).fill('const x = 1;').join('\n') + '\nreturn items;' }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.suggestions.some(s => s.includes('error handling'))); }); it('should validate async operations without await', () => { const nodeType = 'nodes-base.code'; const config = { language: 'javascript', jsCode: ` const promise = fetch('https://api.example.com'); return [{json: {data: promise}}]; ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('Async operation without await') )).toBe(true); }); }); describe('Python Code node validation', () => { it('should validate Python code syntax', () => { const nodeType = 'nodes-base.code'; const config = { language: 'python', pythonCode: ` def process_data(): return [{"json": {"test": True}] # Missing closing bracket ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.errors.some(e => e.type === 'syntax_error' && e.message.includes('Unmatched bracket') )).toBe(true); }); it('should detect mixed indentation in Python code', () => { const nodeType = 'nodes-base.code'; const config = { language: 'python', pythonCode: ` def process(): x = 1 y = 2 # This line uses tabs return [{"json": {"x": x, "y": y}}] ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.errors.some(e => e.type === 'syntax_error' && e.message.includes('Mixed indentation') )).toBe(true); }); it('should warn about incorrect n8n return patterns', () => { const nodeType = 'nodes-base.code'; const config = { language: 'python', pythonCode: ` result = {"data": "value"} return result # Should return array of objects with json key ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'invalid_value' && w.message.includes('Must return array of objects with json key') )).toBe(true); }); it('should warn about using external libraries in Python code', () => { const nodeType = 'nodes-base.code'; const config = { language: 'python', pythonCode: ` import pandas as pd import requests df = pd.DataFrame(items) response = requests.get('https://api.example.com') return [{"json": {"data": response.json()}}] ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'invalid_value' && w.message.includes('External libraries not available') )).toBe(true); }); it('should validate Python code with print statements', () => { const nodeType = 'nodes-base.code'; const config = { language: 'python', pythonCode: ` print("Debug:", items) processed = [] for item in items: print(f"Processing: {item}") processed.append({"json": item["json"]}) return processed ` }; const properties = [ { name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('print() output appears in n8n execution logs') )).toBe(true); }); }); describe('Database node validation', () => { it('should validate database query security', () => { const nodeType = 'nodes-base.postgres'; const config = { query: 'DELETE FROM users;' // Missing WHERE clause }; const properties = [ { name: 'query', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'security' && w.message.includes('DELETE query without WHERE clause') )).toBe(true); }); it('should check for SQL injection vulnerabilities', () => { const nodeType = 'nodes-base.mysql'; const config = { query: 'SELECT * FROM users WHERE id = ${userId}' }; const properties = [ { name: 'query', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.warnings.some(w => w.type === 'security' && w.message.includes('SQL injection') )).toBe(true); }); it('should validate SQL SELECT * performance warning', () => { const nodeType = 'nodes-base.postgres'; const config = { query: 'SELECT * FROM large_table WHERE status = "active"' }; const properties = [ { name: 'query', type: 'string' } ]; const result = ConfigValidator.validate(nodeType, config, properties); expect(result.suggestions.some(s => s.includes('Consider selecting specific columns') )).toBe(true); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/mcp/tools.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import { n8nDocumentationToolsFinal } from '@/mcp/tools'; import { z } from 'zod'; describe('n8nDocumentationToolsFinal', () => { describe('Tool Structure Validation', () => { it('should have all required properties for each tool', () => { n8nDocumentationToolsFinal.forEach(tool => { // Check required properties exist expect(tool).toHaveProperty('name'); expect(tool).toHaveProperty('description'); expect(tool).toHaveProperty('inputSchema'); // Check property types expect(typeof tool.name).toBe('string'); expect(typeof tool.description).toBe('string'); expect(tool.inputSchema).toBeTypeOf('object'); // Name should be non-empty expect(tool.name.length).toBeGreaterThan(0); // Description should be meaningful expect(tool.description.length).toBeGreaterThan(10); }); }); it('should have unique tool names', () => { const names = n8nDocumentationToolsFinal.map(tool => tool.name); const uniqueNames = new Set(names); expect(names.length).toBe(uniqueNames.size); }); it('should have valid JSON Schema for all inputSchemas', () => { // Define a minimal JSON Schema validator using Zod const jsonSchemaValidator = z.object({ type: z.literal('object'), properties: z.record(z.any()).optional(), required: z.array(z.string()).optional(), }); n8nDocumentationToolsFinal.forEach(tool => { expect(() => { jsonSchemaValidator.parse(tool.inputSchema); }).not.toThrow(); }); }); }); describe('Individual Tool Validation', () => { describe('tools_documentation', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'tools_documentation'); it('should exist', () => { expect(tool).toBeDefined(); }); it('should have correct schema', () => { expect(tool?.inputSchema).toMatchObject({ type: 'object', properties: { topic: { type: 'string', description: expect.any(String) }, depth: { type: 'string', enum: ['essentials', 'full'], description: expect.any(String), default: 'essentials' } } }); }); it('should have helpful description', () => { expect(tool?.description).toContain('documentation'); expect(tool?.description).toContain('MCP tools'); }); }); describe('list_nodes', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'list_nodes'); it('should exist', () => { expect(tool).toBeDefined(); }); it('should have correct schema properties', () => { const properties = tool?.inputSchema.properties; expect(properties).toHaveProperty('package'); expect(properties).toHaveProperty('category'); expect(properties).toHaveProperty('developmentStyle'); expect(properties).toHaveProperty('isAITool'); expect(properties).toHaveProperty('limit'); }); it('should have correct defaults', () => { expect(tool?.inputSchema.properties.limit.default).toBe(50); }); it('should have proper enum values', () => { expect(tool?.inputSchema.properties.developmentStyle.enum).toEqual(['declarative', 'programmatic']); }); }); describe('get_node_info', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'get_node_info'); it('should exist', () => { expect(tool).toBeDefined(); }); it('should have nodeType as required parameter', () => { expect(tool?.inputSchema.required).toContain('nodeType'); }); it('should mention performance implications in description', () => { expect(tool?.description).toMatch(/100KB\+|large|full/i); }); }); describe('search_nodes', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'search_nodes'); it('should exist', () => { expect(tool).toBeDefined(); }); it('should have query as required parameter', () => { expect(tool?.inputSchema.required).toContain('query'); }); it('should have mode enum with correct values', () => { expect(tool?.inputSchema.properties.mode.enum).toEqual(['OR', 'AND', 'FUZZY']); expect(tool?.inputSchema.properties.mode.default).toBe('OR'); }); it('should have limit with default value', () => { expect(tool?.inputSchema.properties.limit.default).toBe(20); }); }); describe('validate_workflow', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'validate_workflow'); it('should exist', () => { expect(tool).toBeDefined(); }); it('should have workflow as required parameter', () => { expect(tool?.inputSchema.required).toContain('workflow'); }); it('should have options with correct validation settings', () => { const options = tool?.inputSchema.properties.options.properties; expect(options).toHaveProperty('validateNodes'); expect(options).toHaveProperty('validateConnections'); expect(options).toHaveProperty('validateExpressions'); expect(options).toHaveProperty('profile'); }); it('should have correct profile enum values', () => { const profile = tool?.inputSchema.properties.options.properties.profile; expect(profile.enum).toEqual(['minimal', 'runtime', 'ai-friendly', 'strict']); expect(profile.default).toBe('runtime'); }); }); describe('get_templates_for_task', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'get_templates_for_task'); it('should exist', () => { expect(tool).toBeDefined(); }); it('should have task as required parameter', () => { expect(tool?.inputSchema.required).toContain('task'); }); it('should have correct task enum values', () => { const expectedTasks = [ 'ai_automation', 'data_sync', 'webhook_processing', 'email_automation', 'slack_integration', 'data_transformation', 'file_processing', 'scheduling', 'api_integration', 'database_operations' ]; expect(tool?.inputSchema.properties.task.enum).toEqual(expectedTasks); }); }); }); describe('Tool Description Quality', () => { it('should have concise descriptions that fit in one line', () => { n8nDocumentationToolsFinal.forEach(tool => { // Descriptions should be informative but not overly long expect(tool.description.length).toBeLessThan(300); }); }); it('should include examples or key information in descriptions', () => { const toolsWithExamples = [ 'list_nodes', 'get_node_info', 'search_nodes', 'get_node_essentials', 'get_node_documentation' ]; toolsWithExamples.forEach(toolName => { const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); // Should include either example usage, format information, or "nodes-base" expect(tool?.description).toMatch(/example|Example|format|Format|nodes-base|Common:/i); }); }); }); describe('Schema Consistency', () => { it('should use consistent parameter naming', () => { const toolsWithNodeType = n8nDocumentationToolsFinal.filter(tool => tool.inputSchema.properties?.nodeType ); toolsWithNodeType.forEach(tool => { const nodeTypeParam = tool.inputSchema.properties.nodeType; expect(nodeTypeParam.type).toBe('string'); // Should mention the prefix requirement expect(nodeTypeParam.description).toMatch(/nodes-base|prefix/i); }); }); it('should have consistent limit parameter defaults', () => { const toolsWithLimit = n8nDocumentationToolsFinal.filter(tool => tool.inputSchema.properties?.limit ); toolsWithLimit.forEach(tool => { const limitParam = tool.inputSchema.properties.limit; expect(limitParam.type).toBe('number'); expect(limitParam.default).toBeDefined(); expect(limitParam.default).toBeGreaterThan(0); }); }); }); describe('Tool Categories Coverage', () => { it('should have tools for all major categories', () => { const categories = { discovery: ['list_nodes', 'search_nodes', 'list_ai_tools'], configuration: ['get_node_info', 'get_node_essentials', 'get_node_documentation'], validation: ['validate_node_operation', 'validate_workflow', 'validate_node_minimal'], templates: ['list_tasks', 'search_templates', 'list_templates', 'get_template', 'list_node_templates'], // get_node_for_task removed in v2.15.0 documentation: ['tools_documentation'] }; Object.entries(categories).forEach(([category, expectedTools]) => { expectedTools.forEach(toolName => { const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); expect(tool).toBeDefined(); }); }); }); }); describe('Parameter Validation', () => { it('should have proper type definitions for all parameters', () => { const validTypes = ['string', 'number', 'boolean', 'object', 'array']; n8nDocumentationToolsFinal.forEach(tool => { if (tool.inputSchema.properties) { Object.entries(tool.inputSchema.properties).forEach(([paramName, param]) => { expect(validTypes).toContain(param.type); expect(param.description).toBeDefined(); }); } }); }); it('should mark required parameters correctly', () => { const toolsWithRequired = n8nDocumentationToolsFinal.filter(tool => tool.inputSchema.required && tool.inputSchema.required.length > 0 ); toolsWithRequired.forEach(tool => { tool.inputSchema.required!.forEach(requiredParam => { expect(tool.inputSchema.properties).toHaveProperty(requiredParam); }); }); }); }); describe('Edge Cases', () => { it('should handle tools with no parameters', () => { const toolsWithNoParams = ['list_ai_tools', 'get_database_statistics']; toolsWithNoParams.forEach(toolName => { const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); expect(tool).toBeDefined(); expect(Object.keys(tool?.inputSchema.properties || {}).length).toBe(0); }); }); it('should have array parameters defined correctly', () => { const toolsWithArrays = ['list_node_templates']; toolsWithArrays.forEach(toolName => { const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); const arrayParam = tool?.inputSchema.properties.nodeTypes; expect(arrayParam?.type).toBe('array'); expect(arrayParam?.items).toBeDefined(); expect(arrayParam?.items.type).toBe('string'); }); }); }); describe('New Template Tools', () => { describe('list_templates', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'list_templates'); it('should exist and be properly defined', () => { expect(tool).toBeDefined(); expect(tool?.description).toContain('minimal data'); }); it('should have correct parameters', () => { expect(tool?.inputSchema.properties).toHaveProperty('limit'); expect(tool?.inputSchema.properties).toHaveProperty('offset'); expect(tool?.inputSchema.properties).toHaveProperty('sortBy'); const limitParam = tool?.inputSchema.properties.limit; expect(limitParam.type).toBe('number'); expect(limitParam.minimum).toBe(1); expect(limitParam.maximum).toBe(100); const offsetParam = tool?.inputSchema.properties.offset; expect(offsetParam.type).toBe('number'); expect(offsetParam.minimum).toBe(0); const sortByParam = tool?.inputSchema.properties.sortBy; expect(sortByParam.enum).toEqual(['views', 'created_at', 'name']); }); it('should have no required parameters', () => { expect(tool?.inputSchema.required).toBeUndefined(); }); }); describe('get_template (enhanced)', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'get_template'); it('should exist and support mode parameter', () => { expect(tool).toBeDefined(); expect(tool?.description).toContain('mode'); }); it('should have mode parameter with correct values', () => { expect(tool?.inputSchema.properties).toHaveProperty('mode'); const modeParam = tool?.inputSchema.properties.mode; expect(modeParam.enum).toEqual(['nodes_only', 'structure', 'full']); expect(modeParam.default).toBe('full'); }); it('should require templateId parameter', () => { expect(tool?.inputSchema.required).toContain('templateId'); }); }); describe('search_templates_by_metadata', () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === 'search_templates_by_metadata'); it('should exist in the tools array', () => { expect(tool).toBeDefined(); expect(tool?.name).toBe('search_templates_by_metadata'); }); it('should have proper description', () => { expect(tool?.description).toContain('Search templates by AI-generated metadata'); expect(tool?.description).toContain('category'); expect(tool?.description).toContain('complexity'); }); it('should have correct input schema structure', () => { expect(tool?.inputSchema.type).toBe('object'); expect(tool?.inputSchema.properties).toBeDefined(); expect(tool?.inputSchema.required).toBeUndefined(); // All parameters are optional }); it('should have category parameter with proper schema', () => { const categoryProp = tool?.inputSchema.properties?.category; expect(categoryProp).toBeDefined(); expect(categoryProp.type).toBe('string'); expect(categoryProp.description).toContain('category'); }); it('should have complexity parameter with enum values', () => { const complexityProp = tool?.inputSchema.properties?.complexity; expect(complexityProp).toBeDefined(); expect(complexityProp.enum).toEqual(['simple', 'medium', 'complex']); expect(complexityProp.description).toContain('complexity'); }); it('should have time-based parameters with numeric constraints', () => { const maxTimeProp = tool?.inputSchema.properties?.maxSetupMinutes; const minTimeProp = tool?.inputSchema.properties?.minSetupMinutes; expect(maxTimeProp).toBeDefined(); expect(maxTimeProp.type).toBe('number'); expect(maxTimeProp.maximum).toBe(480); expect(maxTimeProp.minimum).toBe(5); expect(minTimeProp).toBeDefined(); expect(minTimeProp.type).toBe('number'); expect(minTimeProp.maximum).toBe(480); expect(minTimeProp.minimum).toBe(5); }); it('should have service and audience parameters', () => { const serviceProp = tool?.inputSchema.properties?.requiredService; const audienceProp = tool?.inputSchema.properties?.targetAudience; expect(serviceProp).toBeDefined(); expect(serviceProp.type).toBe('string'); expect(serviceProp.description).toContain('service'); expect(audienceProp).toBeDefined(); expect(audienceProp.type).toBe('string'); expect(audienceProp.description).toContain('audience'); }); it('should have pagination parameters', () => { const limitProp = tool?.inputSchema.properties?.limit; const offsetProp = tool?.inputSchema.properties?.offset; expect(limitProp).toBeDefined(); expect(limitProp.type).toBe('number'); expect(limitProp.default).toBe(20); expect(limitProp.maximum).toBe(100); expect(limitProp.minimum).toBe(1); expect(offsetProp).toBeDefined(); expect(offsetProp.type).toBe('number'); expect(offsetProp.default).toBe(0); expect(offsetProp.minimum).toBe(0); }); it('should include all expected properties', () => { const properties = Object.keys(tool?.inputSchema.properties || {}); const expectedProperties = [ 'category', 'complexity', 'maxSetupMinutes', 'minSetupMinutes', 'requiredService', 'targetAudience', 'limit', 'offset' ]; expectedProperties.forEach(prop => { expect(properties).toContain(prop); }); }); it('should have appropriate additionalProperties setting', () => { expect(tool?.inputSchema.additionalProperties).toBe(false); }); }); describe('Enhanced pagination support', () => { const paginatedTools = ['list_node_templates', 'search_templates', 'get_templates_for_task', 'search_templates_by_metadata']; paginatedTools.forEach(toolName => { describe(toolName, () => { const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); it('should support limit parameter', () => { expect(tool?.inputSchema.properties).toHaveProperty('limit'); const limitParam = tool?.inputSchema.properties.limit; expect(limitParam.type).toBe('number'); expect(limitParam.minimum).toBeGreaterThanOrEqual(1); expect(limitParam.maximum).toBeGreaterThanOrEqual(50); }); it('should support offset parameter', () => { expect(tool?.inputSchema.properties).toHaveProperty('offset'); const offsetParam = tool?.inputSchema.properties.offset; expect(offsetParam.type).toBe('number'); expect(offsetParam.minimum).toBe(0); }); }); }); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/mcp/handlers-workflow-diff.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { handleUpdatePartialWorkflow } from '@/mcp/handlers-workflow-diff'; import { WorkflowDiffEngine } from '@/services/workflow-diff-engine'; import { N8nApiClient } from '@/services/n8n-api-client'; import { N8nApiError, N8nAuthenticationError, N8nNotFoundError, N8nValidationError, N8nRateLimitError, N8nServerError, } from '@/utils/n8n-errors'; import { z } from 'zod'; // Mock dependencies vi.mock('@/services/workflow-diff-engine'); vi.mock('@/services/n8n-api-client'); vi.mock('@/config/n8n-api'); vi.mock('@/utils/logger'); vi.mock('@/mcp/handlers-n8n-manager', () => ({ getN8nApiClient: vi.fn(), })); // Import mocked modules import { getN8nApiClient } from '@/mcp/handlers-n8n-manager'; import { logger } from '@/utils/logger'; describe('handlers-workflow-diff', () => { let mockApiClient: any; let mockDiffEngine: any; // Helper function to create test workflow const createTestWorkflow = (overrides = {}) => ({ id: 'test-workflow-id', name: 'Test Workflow', active: true, nodes: [ { id: 'node1', name: 'Start', type: 'n8n-nodes-base.start', typeVersion: 1, position: [100, 100], parameters: {}, }, { id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 3, position: [300, 100], parameters: { url: 'https://api.test.com' }, }, ], connections: { 'Start': { main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]], }, }, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', tags: [], settings: {}, ...overrides, }); beforeEach(() => { vi.clearAllMocks(); // Setup mock API client mockApiClient = { getWorkflow: vi.fn(), updateWorkflow: vi.fn(), }; // Setup mock diff engine mockDiffEngine = { applyDiff: vi.fn(), }; // Mock the API client getter vi.mocked(getN8nApiClient).mockReturnValue(mockApiClient); // Mock WorkflowDiffEngine constructor vi.mocked(WorkflowDiffEngine).mockImplementation(() => mockDiffEngine); // Set up default environment process.env.DEBUG_MCP = 'false'; }); describe('handleUpdatePartialWorkflow', () => { it('should apply diff operations successfully', async () => { const testWorkflow = createTestWorkflow(); const updatedWorkflow = { ...testWorkflow, nodes: [ ...testWorkflow.nodes, { id: 'node3', name: 'New Node', type: 'n8n-nodes-base.set', typeVersion: 1, position: [500, 100], parameters: {}, }, ], connections: { ...testWorkflow.connections, 'HTTP Request': { main: [[{ node: 'New Node', type: 'main', index: 0 }]], }, }, }; const diffRequest = { id: 'test-workflow-id', operations: [ { type: 'addNode', node: { id: 'node3', name: 'New Node', type: 'n8n-nodes-base.set', typeVersion: 1, position: [500, 100], parameters: {}, }, }, ], }; mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: true, workflow: updatedWorkflow, operationsApplied: 1, message: 'Successfully applied 1 operation', errors: [], applied: [0], failed: [], }); mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); const result = await handleUpdatePartialWorkflow(diffRequest); expect(result).toEqual({ success: true, data: updatedWorkflow, message: 'Workflow "Test Workflow" updated successfully. Applied 1 operations.', details: { operationsApplied: 1, workflowId: 'test-workflow-id', workflowName: 'Test Workflow', applied: [0], failed: [], errors: [], }, }); expect(mockApiClient.getWorkflow).toHaveBeenCalledWith('test-workflow-id'); expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); expect(mockApiClient.updateWorkflow).toHaveBeenCalledWith('test-workflow-id', updatedWorkflow); }); it('should handle validation-only mode', async () => { const testWorkflow = createTestWorkflow(); const diffRequest = { id: 'test-workflow-id', operations: [ { type: 'updateNode', nodeId: 'node2', updates: { name: 'Updated HTTP Request' }, }, ], validateOnly: true, }; mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: true, workflow: testWorkflow, operationsApplied: 1, message: 'Validation successful', errors: [], }); const result = await handleUpdatePartialWorkflow(diffRequest); expect(result).toEqual({ success: true, message: 'Validation successful', data: { valid: true, operationsToApply: 1, }, }); expect(mockApiClient.updateWorkflow).not.toHaveBeenCalled(); }); it('should handle multiple operations', async () => { const testWorkflow = createTestWorkflow(); const diffRequest = { id: 'test-workflow-id', operations: [ { type: 'updateNode', nodeId: 'node1', updates: { name: 'Updated Start' }, }, { type: 'addNode', node: { id: 'node3', name: 'Set Node', type: 'n8n-nodes-base.set', typeVersion: 1, position: [500, 100], parameters: {}, }, }, { type: 'addConnection', source: 'node2', target: 'node3', sourceOutput: 'main', targetInput: 'main', }, ], }; mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: true, workflow: { ...testWorkflow, nodes: [ { ...testWorkflow.nodes[0], name: 'Updated Start' }, testWorkflow.nodes[1], { id: 'node3', name: 'Set Node', type: 'n8n-nodes-base.set', typeVersion: 1, position: [500, 100], parameters: {}, } ], connections: { 'Updated Start': testWorkflow.connections['Start'], 'HTTP Request': { main: [[{ node: 'Set Node', type: 'main', index: 0 }]], }, }, }, operationsApplied: 3, message: 'Successfully applied 3 operations', errors: [], applied: [0, 1, 2], failed: [], }); mockApiClient.updateWorkflow.mockResolvedValue({ ...testWorkflow }); const result = await handleUpdatePartialWorkflow(diffRequest); expect(result.success).toBe(true); expect(result.message).toContain('Applied 3 operations'); }); it('should handle diff application failures', async () => { const testWorkflow = createTestWorkflow(); const diffRequest = { id: 'test-workflow-id', operations: [ { type: 'updateNode', nodeId: 'non-existent-node', updates: { name: 'Updated' }, }, ], }; mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: false, workflow: null, operationsApplied: 0, message: 'Failed to apply operations', errors: ['Node "non-existent-node" not found'], applied: [], failed: [0], }); const result = await handleUpdatePartialWorkflow(diffRequest); expect(result).toEqual({ success: false, error: 'Failed to apply diff operations', details: { errors: ['Node "non-existent-node" not found'], operationsApplied: 0, applied: [], failed: [0], }, }); expect(mockApiClient.updateWorkflow).not.toHaveBeenCalled(); }); it('should handle API not configured error', async () => { vi.mocked(getN8nApiClient).mockReturnValue(null); const result = await handleUpdatePartialWorkflow({ id: 'test-id', operations: [], }); expect(result).toEqual({ success: false, error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.', }); }); it('should handle workflow not found error', async () => { const notFoundError = new N8nNotFoundError('Workflow', 'non-existent'); mockApiClient.getWorkflow.mockRejectedValue(notFoundError); const result = await handleUpdatePartialWorkflow({ id: 'non-existent', operations: [], }); expect(result).toEqual({ success: false, error: 'Workflow with ID non-existent not found', code: 'NOT_FOUND', }); }); it('should handle API errors during update', async () => { const testWorkflow = createTestWorkflow(); const validationError = new N8nValidationError('Invalid workflow structure', { field: 'connections', message: 'Invalid connection configuration', }); mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: true, workflow: testWorkflow, operationsApplied: 1, message: 'Success', errors: [], }); mockApiClient.updateWorkflow.mockRejectedValue(validationError); const result = await handleUpdatePartialWorkflow({ id: 'test-id', operations: [{ type: 'updateNode', nodeId: 'node1', updates: {} }], }); expect(result).toEqual({ success: false, error: 'Invalid request: Invalid workflow structure', code: 'VALIDATION_ERROR', details: { field: 'connections', message: 'Invalid connection configuration', }, }); }); it('should handle input validation errors', async () => { const invalidInput = { id: 'test-id', operations: [ { // Missing required 'type' field nodeId: 'node1', updates: {}, }, ], }; const result = await handleUpdatePartialWorkflow(invalidInput); expect(result.success).toBe(false); expect(result.error).toBe('Invalid input'); expect(result.details).toHaveProperty('errors'); expect(result.details?.errors).toBeInstanceOf(Array); }); it('should handle complex operation types', async () => { const testWorkflow = createTestWorkflow(); const diffRequest = { id: 'test-workflow-id', operations: [ { type: 'moveNode', nodeId: 'node2', position: [400, 200], }, { type: 'removeConnection', source: 'node1', target: 'node2', sourceOutput: 'main', targetInput: 'main', }, { type: 'updateSettings', settings: { executionOrder: 'v1', timezone: 'America/New_York', }, }, { type: 'addTag', tag: 'automated', }, ], }; mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: true, workflow: { ...testWorkflow, settings: { executionOrder: 'v1' } }, operationsApplied: 4, message: 'Successfully applied 4 operations', errors: [], }); mockApiClient.updateWorkflow.mockResolvedValue({ ...testWorkflow }); const result = await handleUpdatePartialWorkflow(diffRequest); expect(result.success).toBe(true); expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); }); it('should handle debug logging when enabled', async () => { process.env.DEBUG_MCP = 'true'; const testWorkflow = createTestWorkflow(); mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: true, workflow: testWorkflow, operationsApplied: 1, message: 'Success', errors: [], }); mockApiClient.updateWorkflow.mockResolvedValue(testWorkflow); await handleUpdatePartialWorkflow({ id: 'test-id', operations: [{ type: 'updateNode', nodeId: 'node1', updates: {} }], }); expect(logger.debug).toHaveBeenCalledWith( 'Workflow diff request received', expect.objectContaining({ argsType: 'object', operationCount: 1, }) ); }); it('should handle generic errors', async () => { const genericError = new Error('Something went wrong'); mockApiClient.getWorkflow.mockRejectedValue(genericError); const result = await handleUpdatePartialWorkflow({ id: 'test-id', operations: [], }); expect(result).toEqual({ success: false, error: 'Something went wrong', }); expect(logger.error).toHaveBeenCalledWith('Failed to update partial workflow', genericError); }); it('should handle authentication errors', async () => { const authError = new N8nAuthenticationError('Invalid API key'); mockApiClient.getWorkflow.mockRejectedValue(authError); const result = await handleUpdatePartialWorkflow({ id: 'test-id', operations: [], }); expect(result).toEqual({ success: false, error: 'Failed to authenticate with n8n. Please check your API key.', code: 'AUTHENTICATION_ERROR', }); }); it('should handle rate limit errors', async () => { const rateLimitError = new N8nRateLimitError(60); mockApiClient.getWorkflow.mockRejectedValue(rateLimitError); const result = await handleUpdatePartialWorkflow({ id: 'test-id', operations: [], }); expect(result).toEqual({ success: false, error: 'Too many requests. Please wait a moment and try again.', code: 'RATE_LIMIT_ERROR', }); }); it('should handle server errors', async () => { const serverError = new N8nServerError('Internal server error'); mockApiClient.getWorkflow.mockRejectedValue(serverError); const result = await handleUpdatePartialWorkflow({ id: 'test-id', operations: [], }); expect(result).toEqual({ success: false, error: 'Internal server error', code: 'SERVER_ERROR', }); }); it('should validate operation structure', async () => { const testWorkflow = createTestWorkflow(); const diffRequest = { id: 'test-workflow-id', operations: [ { type: 'updateNode', nodeId: 'node1', nodeName: 'Start', // Both nodeId and nodeName provided updates: { name: 'New Start' }, description: 'Update start node name', }, { type: 'addConnection', source: 'node1', target: 'node2', sourceOutput: 'main', targetInput: 'main', sourceIndex: 0, targetIndex: 0, }, ], }; mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: true, workflow: testWorkflow, operationsApplied: 2, message: 'Success', errors: [], }); mockApiClient.updateWorkflow.mockResolvedValue(testWorkflow); const result = await handleUpdatePartialWorkflow(diffRequest); expect(result.success).toBe(true); expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); }); it('should handle empty operations array', async () => { const testWorkflow = createTestWorkflow(); const diffRequest = { id: 'test-workflow-id', operations: [], }; mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: true, workflow: testWorkflow, operationsApplied: 0, message: 'No operations to apply', errors: [], }); mockApiClient.updateWorkflow.mockResolvedValue(testWorkflow); const result = await handleUpdatePartialWorkflow(diffRequest); expect(result.success).toBe(true); expect(result.message).toContain('Applied 0 operations'); }); it('should handle partial diff application', async () => { const testWorkflow = createTestWorkflow(); const diffRequest = { id: 'test-workflow-id', operations: [ { type: 'updateNode', nodeId: 'node1', updates: { name: 'Updated' } }, { type: 'updateNode', nodeId: 'invalid-node', updates: { name: 'Fail' } }, { type: 'addTag', tag: 'test' }, ], }; mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); mockDiffEngine.applyDiff.mockResolvedValue({ success: false, workflow: null, operationsApplied: 1, message: 'Partially applied operations', errors: ['Operation 2 failed: Node "invalid-node" not found'], }); const result = await handleUpdatePartialWorkflow(diffRequest); expect(result).toEqual({ success: false, error: 'Failed to apply diff operations', details: { errors: ['Operation 2 failed: Node "invalid-node" not found'], operationsApplied: 1, }, }); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts: -------------------------------------------------------------------------------- ```typescript import { ToolDocumentation } from '../types'; export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = { name: 'n8n_update_partial_workflow', category: 'workflow_management', essentials: { description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).', keyParameters: ['id', 'operations', 'continueOnError'], example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})', performance: 'Fast (50-200ms)', tips: [ 'Use rewireConnection to change connection targets', 'Use branch="true"/"false" for IF nodes', 'Use case=N for Switch nodes', 'Use cleanStaleConnections to auto-remove broken connections', 'Set ignoreErrors:true on removeConnection for cleanup', 'Use continueOnError mode for best-effort bulk operations', 'Validate with validateOnly first', 'For AI connections, specify sourceOutput type (ai_languageModel, ai_tool, etc.)', 'Batch AI component connections for atomic updates', 'Auto-sanitization: ALL nodes auto-fixed during updates (operator structures, missing metadata)' ] }, full: { description: `Updates workflows using surgical diff operations instead of full replacement. Supports 15 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied. ## Available Operations: ### Node Operations (6 types): - **addNode**: Add a new node with name, type, and position (required) - **removeNode**: Remove a node by ID or name - **updateNode**: Update node properties using dot notation (e.g., 'parameters.url') - **moveNode**: Change node position [x, y] - **enableNode**: Enable a disabled node - **disableNode**: Disable an active node ### Connection Operations (5 types): - **addConnection**: Connect nodes (source→target). Supports smart parameters: branch="true"/"false" for IF nodes, case=N for Switch nodes. - **removeConnection**: Remove connection between nodes (supports ignoreErrors flag) - **rewireConnection**: Change connection target from one node to another. Supports smart parameters. - **cleanStaleConnections**: Auto-remove all connections referencing non-existent nodes - **replaceConnections**: Replace entire connections object ### Metadata Operations (4 types): - **updateSettings**: Modify workflow settings - **updateName**: Rename the workflow - **addTag**: Add a workflow tag - **removeTag**: Remove a workflow tag ## Smart Parameters for Multi-Output Nodes For **IF nodes**, use semantic 'branch' parameter instead of technical sourceIndex: - **branch="true"**: Routes to true branch (sourceIndex=0) - **branch="false"**: Routes to false branch (sourceIndex=1) For **Switch nodes**, use semantic 'case' parameter: - **case=0**: First output - **case=1**: Second output - **case=N**: Nth output Works with addConnection and rewireConnection operations. Explicit sourceIndex overrides smart parameters. ## AI Connection Support Full support for all 8 AI connection types used in n8n AI workflows: **Connection Types**: - **ai_languageModel**: Connect language models (OpenAI, Anthropic, Google Gemini) to AI Agents - **ai_tool**: Connect tools (HTTP Request Tool, Code Tool, etc.) to AI Agents - **ai_memory**: Connect memory systems (Window Buffer, Conversation Summary) to AI Agents - **ai_outputParser**: Connect output parsers (Structured, JSON) to AI Agents - **ai_embedding**: Connect embedding models to Vector Stores - **ai_vectorStore**: Connect vector stores to Vector Store Tools - **ai_document**: Connect document loaders to Vector Stores - **ai_textSplitter**: Connect text splitters to document processing chains **AI Connection Examples**: - Single connection: \`{type: "addConnection", source: "OpenAI", target: "AI Agent", sourceOutput: "ai_languageModel"}\` - Fallback model: Use targetIndex (0=primary, 1=fallback) for dual language model setup - Multiple tools: Batch multiple \`sourceOutput: "ai_tool"\` connections to one AI Agent - Vector retrieval: Chain ai_embedding → ai_vectorStore → ai_tool → AI Agent **Best Practices**: - Always specify \`sourceOutput\` for AI connections (defaults to "main" if omitted) - Connect language model BEFORE creating/enabling AI Agent (validation requirement) - Use atomic mode (default) when setting up AI workflows to ensure complete configuration - Validate AI workflows after changes with \`n8n_validate_workflow\` tool ## Cleanup & Recovery Features ### Automatic Cleanup The **cleanStaleConnections** operation automatically removes broken connection references after node renames/deletions. Essential for workflow recovery. ### Best-Effort Mode Set **continueOnError: true** to apply valid operations even if some fail. Returns detailed results showing which operations succeeded/failed. Perfect for bulk cleanup operations. ### Graceful Error Handling Add **ignoreErrors: true** to removeConnection operations to prevent failures when connections don't exist. ## Auto-Sanitization System ### What Gets Auto-Fixed When ANY workflow update is made, ALL nodes in the workflow are automatically sanitized to ensure complete metadata and correct structure: 1. **Operator Structure Fixes**: - Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed - Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added - Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\` 2. **Missing Metadata Added**: - IF v2.2+ nodes get complete \`conditions.options\` structure if missing - Switch v3.2+ nodes get complete \`conditions.options\` for all rules - Required fields: \`{version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}\` ### Sanitization Scope - Runs on **ALL nodes** in the workflow, not just modified ones - Triggered by ANY update operation (addNode, updateNode, addConnection, etc.) - Prevents workflow corruption that would make UI unrenderable ### Limitations Auto-sanitization CANNOT fix: - Broken connections (connections referencing non-existent nodes) - use \`cleanStaleConnections\` - Branch count mismatches (e.g., Switch with 3 rules but only 2 outputs) - requires manual connection fixes - Workflows in paradoxical corrupt states (API returns corrupt data, API rejects updates) - must recreate workflow ### Recovery Guidance If validation still fails after auto-sanitization: 1. Check error details for specific issues 2. Use \`validate_workflow\` to see all validation errors 3. For connection issues, use \`cleanStaleConnections\` operation 4. For branch mismatches, add missing output connections 5. For paradoxical corrupted workflows, create new workflow and migrate nodes`, parameters: { id: { type: 'string', required: true, description: 'Workflow ID to update' }, operations: { type: 'array', required: true, description: 'Array of diff operations. Each must have "type" field and operation-specific properties. Nodes can be referenced by ID or name.' }, validateOnly: { type: 'boolean', description: 'If true, only validate operations without applying them' }, continueOnError: { type: 'boolean', description: 'If true, apply valid operations even if some fail (best-effort mode). Returns applied and failed operation indices. Default: false (atomic)' } }, returns: 'Updated workflow object or validation results if validateOnly=true', examples: [ '// Add a basic node (minimal configuration)\nn8n_update_partial_workflow({id: "abc", operations: [{type: "addNode", node: {name: "Process Data", type: "n8n-nodes-base.set", position: [400, 300], parameters: {}}}]})', '// Add node with full configuration\nn8n_update_partial_workflow({id: "def", operations: [{type: "addNode", node: {name: "Send Slack Alert", type: "n8n-nodes-base.slack", position: [600, 300], typeVersion: 2, parameters: {resource: "message", operation: "post", channel: "#alerts", text: "Success!"}}}]})', '// Add node AND connect it (common pattern)\nn8n_update_partial_workflow({id: "ghi", operations: [\n {type: "addNode", node: {name: "HTTP Request", type: "n8n-nodes-base.httpRequest", position: [400, 300], parameters: {url: "https://api.example.com", method: "GET"}}},\n {type: "addConnection", source: "Webhook", target: "HTTP Request"}\n]})', '// Rewire connection from one target to another\nn8n_update_partial_workflow({id: "xyz", operations: [{type: "rewireConnection", source: "Webhook", from: "Old Handler", to: "New Handler"}]})', '// Smart parameter: IF node true branch\nn8n_update_partial_workflow({id: "abc", operations: [{type: "addConnection", source: "IF", target: "Success Handler", branch: "true"}]})', '// Smart parameter: IF node false branch\nn8n_update_partial_workflow({id: "def", operations: [{type: "addConnection", source: "IF", target: "Error Handler", branch: "false"}]})', '// Smart parameter: Switch node case routing\nn8n_update_partial_workflow({id: "ghi", operations: [\n {type: "addConnection", source: "Switch", target: "Handler A", case: 0},\n {type: "addConnection", source: "Switch", target: "Handler B", case: 1},\n {type: "addConnection", source: "Switch", target: "Handler C", case: 2}\n]})', '// Rewire with smart parameter\nn8n_update_partial_workflow({id: "jkl", operations: [{type: "rewireConnection", source: "IF", from: "Old True Handler", to: "New True Handler", branch: "true"}]})', '// Add multiple nodes in batch\nn8n_update_partial_workflow({id: "mno", operations: [\n {type: "addNode", node: {name: "Filter", type: "n8n-nodes-base.filter", position: [400, 300], parameters: {}}},\n {type: "addNode", node: {name: "Transform", type: "n8n-nodes-base.set", position: [600, 300], parameters: {}}},\n {type: "addConnection", source: "Filter", target: "Transform"}\n]})', '// Clean up stale connections after node renames/deletions\nn8n_update_partial_workflow({id: "pqr", operations: [{type: "cleanStaleConnections"}]})', '// Remove connection gracefully (no error if it doesn\'t exist)\nn8n_update_partial_workflow({id: "stu", operations: [{type: "removeConnection", source: "Old Node", target: "Target", ignoreErrors: true}]})', '// Best-effort mode: apply what works, report what fails\nn8n_update_partial_workflow({id: "vwx", operations: [\n {type: "updateName", name: "Fixed Workflow"},\n {type: "removeConnection", source: "Broken", target: "Node"},\n {type: "cleanStaleConnections"}\n], continueOnError: true})', '// Update node parameter\nn8n_update_partial_workflow({id: "yza", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.url": "https://api.example.com"}}]})', '// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})', '\n// ============ AI CONNECTION EXAMPLES ============', '// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})', '// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})', '// Connect memory to AI Agent\nn8n_update_partial_workflow({id: "ai3", operations: [{type: "addConnection", source: "Window Buffer Memory", target: "AI Agent", sourceOutput: "ai_memory"}]})', '// Connect output parser to AI Agent\nn8n_update_partial_workflow({id: "ai4", operations: [{type: "addConnection", source: "Structured Output Parser", target: "AI Agent", sourceOutput: "ai_outputParser"}]})', '// Complete AI Agent setup: Add language model, tools, and memory\nn8n_update_partial_workflow({id: "ai5", operations: [\n {type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"},\n {type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "Code Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "Window Buffer Memory", target: "AI Agent", sourceOutput: "ai_memory"}\n]})', '// Add fallback model to AI Agent (requires v2.1+)\nn8n_update_partial_workflow({id: "ai6", operations: [\n {type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 0},\n {type: "addConnection", source: "Anthropic Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 1}\n]})', '// Vector Store setup: Connect embeddings and documents\nn8n_update_partial_workflow({id: "ai7", operations: [\n {type: "addConnection", source: "Embeddings OpenAI", target: "Pinecone Vector Store", sourceOutput: "ai_embedding"},\n {type: "addConnection", source: "Default Data Loader", target: "Pinecone Vector Store", sourceOutput: "ai_document"}\n]})', '// Connect Vector Store Tool to AI Agent (retrieval setup)\nn8n_update_partial_workflow({id: "ai8", operations: [\n {type: "addConnection", source: "Pinecone Vector Store", target: "Vector Store Tool", sourceOutput: "ai_vectorStore"},\n {type: "addConnection", source: "Vector Store Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})', '// Rewire AI Agent to use different language model\nn8n_update_partial_workflow({id: "ai9", operations: [{type: "rewireConnection", source: "AI Agent", from: "OpenAI Chat Model", to: "Anthropic Chat Model", sourceOutput: "ai_languageModel"}]})', '// Replace all AI tools for an agent\nn8n_update_partial_workflow({id: "ai10", operations: [\n {type: "removeConnection", source: "Old Tool 1", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "removeConnection", source: "Old Tool 2", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New HTTP Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New Code Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})' ], useCases: [ 'Rewire connections when replacing nodes', 'Route IF/Switch node outputs with semantic parameters', 'Clean up broken workflows after node renames/deletions', 'Bulk connection cleanup with best-effort mode', 'Update single node parameters', 'Replace all connections at once', 'Graceful cleanup operations that don\'t fail', 'Enable/disable nodes', 'Rename workflows or nodes', 'Manage tags efficiently', 'Connect AI components (language models, tools, memory, parsers)', 'Set up AI Agent workflows with multiple tools', 'Add fallback language models to AI Agents', 'Configure Vector Store retrieval systems', 'Swap language models in existing AI workflows', 'Batch-update AI tool connections' ], performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.', bestPractices: [ 'Use rewireConnection instead of remove+add for changing targets', 'Use branch="true"/"false" for IF nodes instead of sourceIndex', 'Use case=N for Switch nodes instead of sourceIndex', 'Use cleanStaleConnections after renaming/removing nodes', 'Use continueOnError for bulk cleanup operations', 'Set ignoreErrors:true on removeConnection for graceful cleanup', 'Use validateOnly to test operations before applying', 'Group related changes in one call', 'Check operation order for dependencies', 'Use atomic mode (default) for critical updates', 'For AI connections, always specify sourceOutput (ai_languageModel, ai_tool, ai_memory, etc.)', 'Connect language model BEFORE adding AI Agent to ensure validation passes', 'Use targetIndex for fallback models (primary=0, fallback=1)', 'Batch AI component connections in a single operation for atomicity', 'Validate AI workflows after connection changes to catch configuration errors' ], pitfalls: [ '**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access', 'Atomic mode (default): all operations must succeed or none are applied', 'continueOnError breaks atomic guarantees - use with caution', 'Order matters for dependent operations (e.g., must add node before connecting to it)', 'Node references accept ID or name, but name must be unique', 'Node names with special characters (apostrophes, quotes) work correctly', 'For best compatibility, prefer node IDs over names when dealing with special characters', 'Use "updates" property for updateNode operations: {type: "updateNode", updates: {...}}', 'Smart parameters (branch, case) only work with IF and Switch nodes - ignored for other node types', 'Explicit sourceIndex overrides smart parameters (branch, case) if both provided', 'cleanStaleConnections removes ALL broken connections - cannot be selective', 'replaceConnections overwrites entire connections object - all previous connections lost', '**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (isEmpty, isNotEmpty) automatically get singleValue:true added', '**Auto-sanitization runs on ALL nodes**: When ANY update is made, ALL nodes in the workflow are sanitized (not just modified ones)', '**Auto-sanitization cannot fix everything**: It fixes operator structures and missing metadata, but cannot fix broken connections or branch mismatches', '**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated' ], relatedTools: ['n8n_update_full_workflow', 'n8n_get_workflow', 'validate_workflow', 'tools_documentation'] } }; ``` -------------------------------------------------------------------------------- /tests/unit/docker/edge-cases.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; describe('Docker Config Edge Cases', () => { let tempDir: string; let configPath: string; const parseConfigPath = path.resolve(__dirname, '../../../docker/parse-config.js'); beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edge-cases-test-')); configPath = path.join(tempDir, 'config.json'); }); afterEach(() => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); } }); describe('Data type edge cases', () => { it('should handle JavaScript number edge cases', () => { // Note: JSON.stringify converts Infinity/-Infinity/NaN to null // So we need to test with a pre-stringified JSON that would have these values const configJson = `{ "max_safe_int": ${Number.MAX_SAFE_INTEGER}, "min_safe_int": ${Number.MIN_SAFE_INTEGER}, "positive_zero": 0, "negative_zero": -0, "very_small": 1e-308, "very_large": 1e308, "float_precision": 0.30000000000000004 }`; fs.writeFileSync(configPath, configJson); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); expect(output).toContain(`export MAX_SAFE_INT='${Number.MAX_SAFE_INTEGER}'`); expect(output).toContain(`export MIN_SAFE_INT='${Number.MIN_SAFE_INTEGER}'`); expect(output).toContain("export POSITIVE_ZERO='0'"); expect(output).toContain("export NEGATIVE_ZERO='0'"); // -0 becomes 0 in JSON expect(output).toContain("export VERY_SMALL='1e-308'"); expect(output).toContain("export VERY_LARGE='1e+308'"); expect(output).toContain("export FLOAT_PRECISION='0.30000000000000004'"); // Test null values (what Infinity/NaN become in JSON) const configWithNull = { test_null: null, test_array: [1, 2], test_undefined: undefined }; fs.writeFileSync(configPath, JSON.stringify(configWithNull)); const output2 = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); // null values and arrays are skipped expect(output2).toBe(''); }); it('should handle unusual but valid JSON structures', () => { const config = { "": "empty key", "123": "numeric key", "true": "boolean key", "null": "null key", "undefined": "undefined key", "[object Object]": "object string key", "key\nwith\nnewlines": "multiline key", "key\twith\ttabs": "tab key", "🔑": "emoji key", "ключ": "cyrillic key", "キー": "japanese key", "مفتاح": "arabic key" }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); // Empty key is skipped (becomes EMPTY_KEY and then filtered out) expect(output).not.toContain("empty key"); // Numeric key gets prefixed with underscore expect(output).toContain("export _123='numeric key'"); // Other keys are transformed expect(output).toContain("export TRUE='boolean key'"); expect(output).toContain("export NULL='null key'"); expect(output).toContain("export UNDEFINED='undefined key'"); expect(output).toContain("export OBJECT_OBJECT='object string key'"); expect(output).toContain("export KEY_WITH_NEWLINES='multiline key'"); expect(output).toContain("export KEY_WITH_TABS='tab key'"); // Non-ASCII characters are replaced with underscores // But if the result is empty after sanitization, they're skipped const lines = output.trim().split('\n'); // emoji, cyrillic, japanese, arabic keys all become empty after sanitization and are skipped expect(lines.length).toBe(7); // Only the ASCII-based keys remain }); it('should handle circular reference prevention in nested configs', () => { // Create a config that would have circular references if not handled properly const config = { level1: { level2: { level3: { circular_ref: "This would reference level1 in a real circular structure" } }, sibling: { ref_to_level2: "Reference to sibling" } } }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); expect(output).toContain("export LEVEL1_LEVEL2_LEVEL3_CIRCULAR_REF='This would reference level1 in a real circular structure'"); expect(output).toContain("export LEVEL1_SIBLING_REF_TO_LEVEL2='Reference to sibling'"); }); }); describe('File system edge cases', () => { it('should handle permission errors gracefully', () => { if (process.platform === 'win32') { // Skip on Windows as permission handling is different return; } // Create a file with no read permissions fs.writeFileSync(configPath, '{"test": "value"}'); fs.chmodSync(configPath, 0o000); try { const output = execSync(`node "${parseConfigPath}" "${configPath}" 2>&1`, { encoding: 'utf8' }); // Should exit silently even with permission error expect(output).toBe(''); } finally { // Restore permissions for cleanup fs.chmodSync(configPath, 0o644); } }); it('should handle symlinks correctly', () => { const actualConfig = path.join(tempDir, 'actual-config.json'); const symlinkPath = path.join(tempDir, 'symlink-config.json'); fs.writeFileSync(actualConfig, '{"symlink_test": "value"}'); fs.symlinkSync(actualConfig, symlinkPath); const output = execSync(`node "${parseConfigPath}" "${symlinkPath}"`, { encoding: 'utf8' }); expect(output).toContain("export SYMLINK_TEST='value'"); }); it('should handle very large config files', () => { // Create a large config with many keys const largeConfig: Record<string, any> = {}; for (let i = 0; i < 10000; i++) { largeConfig[`key_${i}`] = `value_${i}`; } fs.writeFileSync(configPath, JSON.stringify(largeConfig)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); const lines = output.trim().split('\n'); expect(lines.length).toBe(10000); expect(output).toContain("export KEY_0='value_0'"); expect(output).toContain("export KEY_9999='value_9999'"); }); }); describe('JSON parsing edge cases', () => { it('should handle various invalid JSON formats', () => { const invalidJsonCases = [ '{invalid}', // Missing quotes "{'single': 'quotes'}", // Single quotes '{test: value}', // Unquoted keys '{"test": undefined}', // Undefined value '{"test": function() {}}', // Function '{,}', // Invalid structure '{"a": 1,}', // Trailing comma 'null', // Just null 'true', // Just boolean '"string"', // Just string '123', // Just number '[]', // Empty array '[1, 2, 3]', // Array ]; invalidJsonCases.forEach(invalidJson => { fs.writeFileSync(configPath, invalidJson); const output = execSync(`node "${parseConfigPath}" "${configPath}" 2>&1`, { encoding: 'utf8' }); // Should exit silently on invalid JSON expect(output).toBe(''); }); }); it('should handle Unicode edge cases in JSON', () => { const config = { // Various Unicode scenarios zero_width: "test\u200B\u200C\u200Dtest", // Zero-width characters bom: "\uFEFFtest", // Byte order mark surrogate_pair: "𝕳𝖊𝖑𝖑𝖔", // Mathematical bold text rtl_text: "مرحبا mixed עברית", // Right-to-left text combining: "é" + "é", // Combining vs precomposed control_chars: "test\u0001\u0002\u0003test", emoji_zwj: "👨👩👧👦", // Family emoji with ZWJ invalid_surrogate: "test\uD800test", // Invalid surrogate }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); // All Unicode should be preserved in values expect(output).toContain("export ZERO_WIDTH='test\u200B\u200C\u200Dtest'"); expect(output).toContain("export BOM='\uFEFFtest'"); expect(output).toContain("export SURROGATE_PAIR='𝕳𝖊𝖑𝖑𝖔'"); expect(output).toContain("export RTL_TEXT='مرحبا mixed עברית'"); expect(output).toContain("export COMBINING='éé'"); expect(output).toContain("export CONTROL_CHARS='test\u0001\u0002\u0003test'"); expect(output).toContain("export EMOJI_ZWJ='👨👩👧👦'"); // Invalid surrogate gets replaced with replacement character expect(output).toContain("export INVALID_SURROGATE='test�test'"); }); }); describe('Environment variable edge cases', () => { it('should handle environment variable name transformations', () => { const config = { "lowercase": "value", "UPPERCASE": "value", "camelCase": "value", "PascalCase": "value", "snake_case": "value", "kebab-case": "value", "dot.notation": "value", "space separated": "value", "special!@#$%^&*()": "value", "123starting-with-number": "value", "ending-with-number123": "value", "-starting-with-dash": "value", "_starting_with_underscore": "value" }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); // Check transformations expect(output).toContain("export LOWERCASE='value'"); expect(output).toContain("export UPPERCASE='value'"); expect(output).toContain("export CAMELCASE='value'"); expect(output).toContain("export PASCALCASE='value'"); expect(output).toContain("export SNAKE_CASE='value'"); expect(output).toContain("export KEBAB_CASE='value'"); expect(output).toContain("export DOT_NOTATION='value'"); expect(output).toContain("export SPACE_SEPARATED='value'"); expect(output).toContain("export SPECIAL='value'"); // special chars removed expect(output).toContain("export _123STARTING_WITH_NUMBER='value'"); // prefixed expect(output).toContain("export ENDING_WITH_NUMBER123='value'"); expect(output).toContain("export STARTING_WITH_DASH='value'"); // dash removed expect(output).toContain("export STARTING_WITH_UNDERSCORE='value'"); // Leading underscore is trimmed }); it('should handle conflicting keys after transformation', () => { const config = { "test_key": "underscore", "test-key": "dash", "test.key": "dot", "test key": "space", "TEST_KEY": "uppercase", nested: { "test_key": "nested_underscore" } }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); // All should be transformed to TEST_KEY const lines = output.trim().split('\n'); const testKeyLines = lines.filter(line => line.includes("TEST_KEY='")); // Script outputs all unique TEST_KEY values it encounters // The parser processes keys in order, outputting each unique var name once expect(testKeyLines.length).toBeGreaterThanOrEqual(1); // Nested one has different prefix expect(output).toContain("export NESTED_TEST_KEY='nested_underscore'"); }); }); describe('Performance edge cases', () => { it('should handle extremely deep nesting efficiently', () => { // Create very deep nesting (script allows up to depth 10, which is 11 levels) const createDeepNested = (depth: number, value: any = "deep_value"): any => { if (depth === 0) return value; return { nested: createDeepNested(depth - 1, value) }; }; // Create nested object with exactly 10 levels const config = createDeepNested(10); fs.writeFileSync(configPath, JSON.stringify(config)); const start = Date.now(); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); const duration = Date.now() - start; // Should complete in reasonable time even with deep nesting expect(duration).toBeLessThan(1000); // Less than 1 second // Should produce the deeply nested key with 10 levels const expectedKey = Array(10).fill('NESTED').join('_'); expect(output).toContain(`export ${expectedKey}='deep_value'`); // Test that 11 levels also works (script allows up to depth 10 = 11 levels) const deepConfig = createDeepNested(11); fs.writeFileSync(configPath, JSON.stringify(deepConfig)); const output2 = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); const elevenLevelKey = Array(11).fill('NESTED').join('_'); expect(output2).toContain(`export ${elevenLevelKey}='deep_value'`); // 11 levels present // Test that 12 levels gets completely blocked (beyond depth limit) const veryDeepConfig = createDeepNested(12); fs.writeFileSync(configPath, JSON.stringify(veryDeepConfig)); const output3 = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); // With 12 levels, recursion limit is exceeded and no output is produced expect(output3).toBe(''); // No output at all }); it('should handle wide objects efficiently', () => { // Create object with many keys at same level const config: Record<string, any> = {}; for (let i = 0; i < 1000; i++) { config[`key_${i}`] = { nested_a: `value_a_${i}`, nested_b: `value_b_${i}`, nested_c: { deep: `deep_${i}` } }; } fs.writeFileSync(configPath, JSON.stringify(config)); const start = Date.now(); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); const duration = Date.now() - start; // Should complete efficiently expect(duration).toBeLessThan(2000); // Less than 2 seconds const lines = output.trim().split('\n'); expect(lines.length).toBe(3000); // 3 values per key × 1000 keys (nested_c.deep is flattened) // Verify format expect(output).toContain("export KEY_0_NESTED_A='value_a_0'"); expect(output).toContain("export KEY_999_NESTED_C_DEEP='deep_999'"); }); }); describe('Mixed content edge cases', () => { it('should handle mixed valid and invalid content', () => { const config = { valid_string: "normal value", valid_number: 42, valid_bool: true, invalid_undefined: undefined, invalid_function: null, // Would be a function but JSON.stringify converts to null invalid_symbol: null, // Would be a Symbol but JSON.stringify converts to null valid_nested: { inner_valid: "works", inner_array: ["ignored", "array"], inner_null: null } }; fs.writeFileSync(configPath, JSON.stringify(config)); const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); // Only valid values should be exported expect(output).toContain("export VALID_STRING='normal value'"); expect(output).toContain("export VALID_NUMBER='42'"); expect(output).toContain("export VALID_BOOL='true'"); expect(output).toContain("export VALID_NESTED_INNER_VALID='works'"); // null values, undefined (becomes undefined in JSON), and arrays are not exported expect(output).not.toContain('INVALID_UNDEFINED'); expect(output).not.toContain('INVALID_FUNCTION'); expect(output).not.toContain('INVALID_SYMBOL'); expect(output).not.toContain('INNER_ARRAY'); expect(output).not.toContain('INNER_NULL'); }); }); describe('Real-world configuration scenarios', () => { it('should handle typical n8n-mcp configuration', () => { const config = { mcp_mode: "http", auth_token: "bearer-token-123", server: { host: "0.0.0.0", port: 3000, cors: { enabled: true, origins: ["http://localhost:3000", "https://app.example.com"] } }, database: { node_db_path: "/data/nodes.db", template_cache_size: 100 }, logging: { level: "info", format: "json", disable_console_output: false }, features: { enable_templates: true, enable_validation: true, validation_profile: "ai-friendly" } }; fs.writeFileSync(configPath, JSON.stringify(config)); // Run with a clean set of environment variables to avoid conflicts // We need to preserve PATH so node can be found const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8', env: { PATH: process.env.PATH, NODE_ENV: 'test' } // Only include PATH and NODE_ENV }); // Verify all configuration is properly exported with export prefix expect(output).toContain("export MCP_MODE='http'"); expect(output).toContain("export AUTH_TOKEN='bearer-token-123'"); expect(output).toContain("export SERVER_HOST='0.0.0.0'"); expect(output).toContain("export SERVER_PORT='3000'"); expect(output).toContain("export SERVER_CORS_ENABLED='true'"); expect(output).toContain("export DATABASE_NODE_DB_PATH='/data/nodes.db'"); expect(output).toContain("export DATABASE_TEMPLATE_CACHE_SIZE='100'"); expect(output).toContain("export LOGGING_LEVEL='info'"); expect(output).toContain("export LOGGING_FORMAT='json'"); expect(output).toContain("export LOGGING_DISABLE_CONSOLE_OUTPUT='false'"); expect(output).toContain("export FEATURES_ENABLE_TEMPLATES='true'"); expect(output).toContain("export FEATURES_ENABLE_VALIDATION='true'"); expect(output).toContain("export FEATURES_VALIDATION_PROFILE='ai-friendly'"); // Arrays should be ignored expect(output).not.toContain('ORIGINS'); }); }); }); ``` -------------------------------------------------------------------------------- /src/services/property-filter.ts: -------------------------------------------------------------------------------- ```typescript /** * PropertyFilter Service * * Intelligently filters node properties to return only essential and commonly-used ones. * Reduces property count from 200+ to 10-20 for better AI agent usability. */ export interface SimplifiedProperty { name: string; displayName: string; type: string; description: string; default?: any; options?: Array<{ value: string; label: string }>; required?: boolean; placeholder?: string; showWhen?: Record<string, any>; usageHint?: string; } export interface EssentialConfig { required: string[]; common: string[]; categoryPriority?: string[]; } export interface FilteredProperties { required: SimplifiedProperty[]; common: SimplifiedProperty[]; } export class PropertyFilter { /** * Curated lists of essential properties for the most commonly used nodes. * Based on analysis of typical workflows and AI agent needs. */ private static ESSENTIAL_PROPERTIES: Record<string, EssentialConfig> = { // HTTP Request - Most used node 'nodes-base.httpRequest': { required: ['url'], common: ['method', 'authentication', 'sendBody', 'contentType', 'sendHeaders'], categoryPriority: ['basic', 'authentication', 'request', 'response', 'advanced'] }, // Webhook - Entry point for many workflows 'nodes-base.webhook': { required: [], common: ['httpMethod', 'path', 'responseMode', 'responseData', 'responseCode'], categoryPriority: ['basic', 'response', 'advanced'] }, // Code - For custom logic 'nodes-base.code': { required: [], common: ['language', 'jsCode', 'pythonCode', 'mode'], categoryPriority: ['basic', 'code', 'advanced'] }, // Set - Data manipulation 'nodes-base.set': { required: [], common: ['mode', 'assignments', 'includeOtherFields', 'options'], categoryPriority: ['basic', 'data', 'advanced'] }, // If - Conditional logic 'nodes-base.if': { required: [], common: ['conditions', 'combineOperation'], categoryPriority: ['basic', 'conditions', 'advanced'] }, // PostgreSQL - Database operations 'nodes-base.postgres': { required: [], common: ['operation', 'table', 'query', 'additionalFields', 'returnAll'], categoryPriority: ['basic', 'query', 'options', 'advanced'] }, // OpenAI - AI operations 'nodes-base.openAi': { required: [], common: ['resource', 'operation', 'modelId', 'prompt', 'messages', 'maxTokens'], categoryPriority: ['basic', 'model', 'input', 'options', 'advanced'] }, // Google Sheets - Spreadsheet operations 'nodes-base.googleSheets': { required: [], common: ['operation', 'documentId', 'sheetName', 'range', 'dataStartRow'], categoryPriority: ['basic', 'location', 'data', 'options', 'advanced'] }, // Slack - Messaging 'nodes-base.slack': { required: [], common: ['resource', 'operation', 'channel', 'text', 'attachments', 'blocks'], categoryPriority: ['basic', 'message', 'formatting', 'advanced'] }, // Email - Email operations 'nodes-base.email': { required: [], common: ['resource', 'operation', 'fromEmail', 'toEmail', 'subject', 'text', 'html'], categoryPriority: ['basic', 'recipients', 'content', 'advanced'] }, // Merge - Combining data streams 'nodes-base.merge': { required: [], common: ['mode', 'joinMode', 'propertyName1', 'propertyName2', 'outputDataFrom'], categoryPriority: ['basic', 'merge', 'advanced'] }, // Function (legacy) - Custom functions 'nodes-base.function': { required: [], common: ['functionCode'], categoryPriority: ['basic', 'code', 'advanced'] }, // Split In Batches - Batch processing 'nodes-base.splitInBatches': { required: [], common: ['batchSize', 'options'], categoryPriority: ['basic', 'options', 'advanced'] }, // Redis - Cache operations 'nodes-base.redis': { required: [], common: ['operation', 'key', 'value', 'keyType', 'expire'], categoryPriority: ['basic', 'data', 'options', 'advanced'] }, // MongoDB - NoSQL operations 'nodes-base.mongoDb': { required: [], common: ['operation', 'collection', 'query', 'fields', 'limit'], categoryPriority: ['basic', 'query', 'options', 'advanced'] }, // MySQL - Database operations 'nodes-base.mySql': { required: [], common: ['operation', 'table', 'query', 'columns', 'additionalFields'], categoryPriority: ['basic', 'query', 'options', 'advanced'] }, // FTP - File transfer 'nodes-base.ftp': { required: [], common: ['operation', 'path', 'fileName', 'binaryData'], categoryPriority: ['basic', 'file', 'options', 'advanced'] }, // SSH - Remote execution 'nodes-base.ssh': { required: [], common: ['resource', 'operation', 'command', 'path', 'cwd'], categoryPriority: ['basic', 'command', 'options', 'advanced'] }, // Execute Command - Local execution 'nodes-base.executeCommand': { required: [], common: ['command', 'cwd'], categoryPriority: ['basic', 'advanced'] }, // GitHub - Version control operations 'nodes-base.github': { required: [], common: ['resource', 'operation', 'owner', 'repository', 'title', 'body'], categoryPriority: ['basic', 'repository', 'content', 'advanced'] } }; /** * Deduplicate properties based on name and display conditions */ static deduplicateProperties(properties: any[]): any[] { const seen = new Map<string, any>(); return properties.filter(prop => { // Skip null/undefined properties if (!prop || !prop.name) { return false; } // Create unique key from name + conditions const conditions = JSON.stringify(prop.displayOptions || {}); const key = `${prop.name}_${conditions}`; if (seen.has(key)) { return false; // Skip duplicate } seen.set(key, prop); return true; }); } /** * Get essential properties for a node type */ static getEssentials(allProperties: any[], nodeType: string): FilteredProperties { // Handle null/undefined properties if (!allProperties) { return { required: [], common: [] }; } // Deduplicate first const uniqueProperties = this.deduplicateProperties(allProperties); const config = this.ESSENTIAL_PROPERTIES[nodeType]; if (!config) { // Fallback for unconfigured nodes return this.inferEssentials(uniqueProperties); } // Extract required properties const required = this.extractProperties(uniqueProperties, config.required, true); // Extract common properties (excluding any already in required) const requiredNames = new Set(required.map(p => p.name)); const common = this.extractProperties(uniqueProperties, config.common, false) .filter(p => !requiredNames.has(p.name)); return { required, common }; } /** * Extract and simplify specified properties */ private static extractProperties( allProperties: any[], propertyNames: string[], markAsRequired: boolean ): SimplifiedProperty[] { const extracted: SimplifiedProperty[] = []; for (const name of propertyNames) { const property = this.findPropertyByName(allProperties, name); if (property) { const simplified = this.simplifyProperty(property); if (markAsRequired) { simplified.required = true; } extracted.push(simplified); } } return extracted; } /** * Find a property by name, including in nested collections */ private static findPropertyByName(properties: any[], name: string): any | undefined { for (const prop of properties) { if (prop.name === name) { return prop; } // Check in nested collections if (prop.type === 'collection' && prop.options) { const found = this.findPropertyByName(prop.options, name); if (found) return found; } // Check in fixed collections if (prop.type === 'fixedCollection' && prop.options) { for (const option of prop.options) { if (option.values) { const found = this.findPropertyByName(option.values, name); if (found) return found; } } } } return undefined; } /** * Simplify a property for AI consumption */ private static simplifyProperty(prop: any): SimplifiedProperty { const simplified: SimplifiedProperty = { name: prop.name, displayName: prop.displayName || prop.name, type: prop.type || 'string', // Default to string if no type specified description: this.extractDescription(prop), required: prop.required || false }; // Include default value if it's simple if (prop.default !== undefined && typeof prop.default !== 'object' || prop.type === 'options' || prop.type === 'multiOptions') { simplified.default = prop.default; } // Include placeholder if (prop.placeholder) { simplified.placeholder = prop.placeholder; } // Simplify options for select fields if (prop.options && Array.isArray(prop.options)) { // Limit options to first 20 for better usability const limitedOptions = prop.options.slice(0, 20); simplified.options = limitedOptions.map((opt: any) => { if (typeof opt === 'string') { return { value: opt, label: opt }; } return { value: opt.value || opt.name, label: opt.name || opt.value || opt.displayName }; }); } // Include simple display conditions (max 2 conditions) if (prop.displayOptions?.show) { const conditions = Object.keys(prop.displayOptions.show); if (conditions.length <= 2) { simplified.showWhen = prop.displayOptions.show; } } // Add usage hints based on property characteristics simplified.usageHint = this.generateUsageHint(prop); return simplified; } /** * Generate helpful usage hints for properties */ private static generateUsageHint(prop: any): string | undefined { // URL properties if (prop.name.toLowerCase().includes('url') || prop.name === 'endpoint') { return 'Enter the full URL including https://'; } // Authentication properties if (prop.name.includes('auth') || prop.name.includes('credential')) { return 'Select authentication method or credentials'; } // JSON properties if (prop.type === 'json' || prop.name.includes('json')) { return 'Enter valid JSON data'; } // Code properties if (prop.type === 'code' || prop.name.includes('code')) { return 'Enter your code here'; } // Boolean with specific behaviors if (prop.type === 'boolean' && prop.displayOptions) { return 'Enabling this will show additional options'; } return undefined; } /** * Extract description from various possible fields */ private static extractDescription(prop: any): string { // Try multiple fields where description might be stored const description = prop.description || prop.hint || prop.placeholder || prop.displayName || ''; // If still empty, generate based on property characteristics if (!description) { return this.generateDescription(prop); } return description; } /** * Generate a description based on property characteristics */ private static generateDescription(prop: any): string { const name = prop.name.toLowerCase(); const type = prop.type; // Common property descriptions const commonDescriptions: Record<string, string> = { 'url': 'The URL to make the request to', 'method': 'HTTP method to use for the request', 'authentication': 'Authentication method to use', 'sendbody': 'Whether to send a request body', 'contenttype': 'Content type of the request body', 'sendheaders': 'Whether to send custom headers', 'jsonbody': 'JSON data to send in the request body', 'headers': 'Custom headers to send with the request', 'timeout': 'Request timeout in milliseconds', 'query': 'SQL query to execute', 'table': 'Database table name', 'operation': 'Operation to perform', 'path': 'Webhook path or file path', 'httpmethod': 'HTTP method to accept', 'responsemode': 'How to respond to the webhook', 'responsecode': 'HTTP response code to return', 'channel': 'Slack channel to send message to', 'text': 'Text content of the message', 'subject': 'Email subject line', 'fromemail': 'Sender email address', 'toemail': 'Recipient email address', 'language': 'Programming language to use', 'jscode': 'JavaScript code to execute', 'pythoncode': 'Python code to execute' }; // Check for exact match if (commonDescriptions[name]) { return commonDescriptions[name]; } // Check for partial matches for (const [key, desc] of Object.entries(commonDescriptions)) { if (name.includes(key)) { return desc; } } // Type-based descriptions if (type === 'boolean') { return `Enable or disable ${prop.displayName || name}`; } else if (type === 'options') { return `Select ${prop.displayName || name}`; } else if (type === 'string') { return `Enter ${prop.displayName || name}`; } else if (type === 'number') { return `Number value for ${prop.displayName || name}`; } else if (type === 'json') { return `JSON data for ${prop.displayName || name}`; } return `Configure ${prop.displayName || name}`; } /** * Infer essentials for nodes without curated lists */ private static inferEssentials(properties: any[]): FilteredProperties { // Extract explicitly required properties (limit to prevent huge results) const required = properties .filter(p => p.name && p.required === true) .slice(0, 10) // Limit required properties .map(p => this.simplifyProperty(p)); // Find common properties (simple, always visible, at root level) const common = properties .filter(p => { return p.name && // Ensure property has a name !p.required && !p.displayOptions && p.type !== 'hidden' && // Filter out hidden properties p.type !== 'notice' && // Filter out notice properties !p.name.startsWith('options') && !p.name.startsWith('_'); // Filter out internal properties }) .slice(0, 10) // Take first 10 simple properties .map(p => this.simplifyProperty(p)); // If we have very few properties, include some conditional ones if (required.length + common.length < 10) { const additional = properties .filter(p => { return p.name && // Ensure property has a name !p.required && p.type !== 'hidden' && // Filter out hidden properties p.displayOptions && Object.keys(p.displayOptions.show || {}).length === 1; }) .slice(0, 10 - (required.length + common.length)) .map(p => this.simplifyProperty(p)); common.push(...additional); } // Total should not exceed 30 properties const totalLimit = 30; if (required.length + common.length > totalLimit) { // Prioritize required properties const requiredCount = Math.min(required.length, 15); const commonCount = totalLimit - requiredCount; return { required: required.slice(0, requiredCount), common: common.slice(0, commonCount) }; } return { required, common }; } /** * Search for properties matching a query */ static searchProperties( allProperties: any[], query: string, maxResults: number = 20 ): SimplifiedProperty[] { // Return empty array for empty query if (!query || query.trim() === '') { return []; } const lowerQuery = query.toLowerCase(); const matches: Array<{ property: any; score: number; path: string }> = []; this.searchPropertiesRecursive(allProperties, lowerQuery, matches); // Sort by score and return top results return matches .sort((a, b) => b.score - a.score) .slice(0, maxResults) .map(match => ({ ...this.simplifyProperty(match.property), path: match.path } as SimplifiedProperty & { path: string })); } /** * Recursively search properties including nested ones */ private static searchPropertiesRecursive( properties: any[], query: string, matches: Array<{ property: any; score: number; path: string }>, path: string = '' ): void { for (const prop of properties) { const currentPath = path ? `${path}.${prop.name}` : prop.name; let score = 0; // Check name match if (prop.name.toLowerCase() === query) { score = 10; // Exact match } else if (prop.name.toLowerCase().startsWith(query)) { score = 8; // Prefix match } else if (prop.name.toLowerCase().includes(query)) { score = 5; // Contains match } // Check display name match if (prop.displayName?.toLowerCase().includes(query)) { score = Math.max(score, 4); } // Check description match if (prop.description?.toLowerCase().includes(query)) { score = Math.max(score, 3); } if (score > 0) { matches.push({ property: prop, score, path: currentPath }); } // Search nested properties if (prop.type === 'collection' && prop.options) { this.searchPropertiesRecursive(prop.options, query, matches, currentPath); } else if (prop.type === 'fixedCollection' && prop.options) { for (const option of prop.options) { if (option.values) { this.searchPropertiesRecursive( option.values, query, matches, `${currentPath}.${option.name}` ); } } } } } } ``` -------------------------------------------------------------------------------- /src/services/operation-similarity-service.ts: -------------------------------------------------------------------------------- ```typescript import { NodeRepository } from '../database/node-repository'; import { logger } from '../utils/logger'; import { ValidationServiceError } from '../errors/validation-service-error'; export interface OperationSuggestion { value: string; confidence: number; reason: string; resource?: string; description?: string; } interface OperationPattern { pattern: string; suggestion: string; confidence: number; reason: string; } export class OperationSimilarityService { private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes private static readonly MIN_CONFIDENCE = 0.3; // 30% minimum confidence to suggest private static readonly MAX_SUGGESTIONS = 5; // Confidence thresholds for better code clarity private static readonly CONFIDENCE_THRESHOLDS = { EXACT: 1.0, VERY_HIGH: 0.95, HIGH: 0.8, MEDIUM: 0.6, MIN_SUBSTRING: 0.7 } as const; private repository: NodeRepository; private operationCache: Map<string, { operations: any[], timestamp: number }> = new Map(); private suggestionCache: Map<string, OperationSuggestion[]> = new Map(); private commonPatterns: Map<string, OperationPattern[]>; constructor(repository: NodeRepository) { this.repository = repository; this.commonPatterns = this.initializeCommonPatterns(); } /** * Clean up expired cache entries to prevent memory leaks * Should be called periodically or before cache operations */ private cleanupExpiredEntries(): void { const now = Date.now(); // Clean operation cache for (const [key, value] of this.operationCache.entries()) { if (now - value.timestamp >= OperationSimilarityService.CACHE_DURATION_MS) { this.operationCache.delete(key); } } // Clean suggestion cache - these don't have timestamps, so clear if cache is too large if (this.suggestionCache.size > 100) { // Keep only the most recent 50 entries const entries = Array.from(this.suggestionCache.entries()); this.suggestionCache.clear(); entries.slice(-50).forEach(([key, value]) => { this.suggestionCache.set(key, value); }); } } /** * Initialize common operation mistake patterns */ private initializeCommonPatterns(): Map<string, OperationPattern[]> { const patterns = new Map<string, OperationPattern[]>(); // Google Drive patterns patterns.set('googleDrive', [ { pattern: 'listFiles', suggestion: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder" to list files' }, { pattern: 'uploadFile', suggestion: 'upload', confidence: 0.95, reason: 'Use "upload" instead of "uploadFile"' }, { pattern: 'deleteFile', suggestion: 'deleteFile', confidence: 1.0, reason: 'Exact match' }, { pattern: 'downloadFile', suggestion: 'download', confidence: 0.95, reason: 'Use "download" instead of "downloadFile"' }, { pattern: 'getFile', suggestion: 'download', confidence: 0.8, reason: 'Use "download" to retrieve file content' }, { pattern: 'listFolders', suggestion: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder"' }, ]); // Slack patterns patterns.set('slack', [ { pattern: 'sendMessage', suggestion: 'send', confidence: 0.95, reason: 'Use "send" instead of "sendMessage"' }, { pattern: 'getMessage', suggestion: 'get', confidence: 0.9, reason: 'Use "get" to retrieve messages' }, { pattern: 'postMessage', suggestion: 'send', confidence: 0.9, reason: 'Use "send" to post messages' }, { pattern: 'deleteMessage', suggestion: 'delete', confidence: 0.95, reason: 'Use "delete" instead of "deleteMessage"' }, { pattern: 'createChannel', suggestion: 'create', confidence: 0.9, reason: 'Use "create" with resource: "channel"' }, ]); // Database patterns (postgres, mysql, mongodb) patterns.set('database', [ { pattern: 'selectData', suggestion: 'select', confidence: 0.95, reason: 'Use "select" instead of "selectData"' }, { pattern: 'insertData', suggestion: 'insert', confidence: 0.95, reason: 'Use "insert" instead of "insertData"' }, { pattern: 'updateData', suggestion: 'update', confidence: 0.95, reason: 'Use "update" instead of "updateData"' }, { pattern: 'deleteData', suggestion: 'delete', confidence: 0.95, reason: 'Use "delete" instead of "deleteData"' }, { pattern: 'query', suggestion: 'select', confidence: 0.7, reason: 'Use "select" for queries' }, { pattern: 'fetch', suggestion: 'select', confidence: 0.7, reason: 'Use "select" to fetch data' }, ]); // HTTP patterns patterns.set('httpRequest', [ { pattern: 'fetch', suggestion: 'GET', confidence: 0.8, reason: 'Use "GET" method for fetching data' }, { pattern: 'send', suggestion: 'POST', confidence: 0.7, reason: 'Use "POST" method for sending data' }, { pattern: 'create', suggestion: 'POST', confidence: 0.8, reason: 'Use "POST" method for creating resources' }, { pattern: 'update', suggestion: 'PUT', confidence: 0.8, reason: 'Use "PUT" method for updating resources' }, { pattern: 'delete', suggestion: 'DELETE', confidence: 0.9, reason: 'Use "DELETE" method' }, ]); // Generic patterns patterns.set('generic', [ { pattern: 'list', suggestion: 'get', confidence: 0.6, reason: 'Consider using "get" or "search"' }, { pattern: 'retrieve', suggestion: 'get', confidence: 0.8, reason: 'Use "get" to retrieve data' }, { pattern: 'fetch', suggestion: 'get', confidence: 0.8, reason: 'Use "get" to fetch data' }, { pattern: 'remove', suggestion: 'delete', confidence: 0.85, reason: 'Use "delete" to remove items' }, { pattern: 'add', suggestion: 'create', confidence: 0.7, reason: 'Use "create" to add new items' }, ]); return patterns; } /** * Find similar operations for an invalid operation using Levenshtein distance * and pattern matching algorithms * * @param nodeType - The n8n node type (e.g., 'nodes-base.slack') * @param invalidOperation - The invalid operation provided by the user * @param resource - Optional resource to filter operations * @param maxSuggestions - Maximum number of suggestions to return (default: 5) * @returns Array of operation suggestions sorted by confidence * * @example * findSimilarOperations('nodes-base.googleDrive', 'listFiles', 'fileFolder') * // Returns: [{ value: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder" to list files' }] */ findSimilarOperations( nodeType: string, invalidOperation: string, resource?: string, maxSuggestions: number = OperationSimilarityService.MAX_SUGGESTIONS ): OperationSuggestion[] { // Clean up expired cache entries periodically if (Math.random() < 0.1) { // 10% chance to cleanup on each call this.cleanupExpiredEntries(); } // Check cache first const cacheKey = `${nodeType}:${invalidOperation}:${resource || ''}`; if (this.suggestionCache.has(cacheKey)) { return this.suggestionCache.get(cacheKey)!; } const suggestions: OperationSuggestion[] = []; // Get valid operations for the node let nodeInfo; try { nodeInfo = this.repository.getNode(nodeType); if (!nodeInfo) { return []; } } catch (error) { logger.warn(`Error getting node ${nodeType}:`, error); return []; } const validOperations = this.getNodeOperations(nodeType, resource); // Early termination for exact match - no suggestions needed for (const op of validOperations) { const opValue = this.getOperationValue(op); if (opValue.toLowerCase() === invalidOperation.toLowerCase()) { return []; // Valid operation, no suggestions needed } } // Check for exact pattern matches first const nodePatterns = this.getNodePatterns(nodeType); for (const pattern of nodePatterns) { if (pattern.pattern.toLowerCase() === invalidOperation.toLowerCase()) { // Type-safe operation value extraction const exists = validOperations.some(op => { const opValue = this.getOperationValue(op); return opValue === pattern.suggestion; }); if (exists) { suggestions.push({ value: pattern.suggestion, confidence: pattern.confidence, reason: pattern.reason, resource }); } } } // Calculate similarity for all valid operations for (const op of validOperations) { const opValue = this.getOperationValue(op); const similarity = this.calculateSimilarity(invalidOperation, opValue); if (similarity >= OperationSimilarityService.MIN_CONFIDENCE) { // Don't add if already suggested by pattern if (!suggestions.some(s => s.value === opValue)) { suggestions.push({ value: opValue, confidence: similarity, reason: this.getSimilarityReason(similarity, invalidOperation, opValue), resource: typeof op === 'object' ? op.resource : undefined, description: typeof op === 'object' ? (op.description || op.name) : undefined }); } } } // Sort by confidence and limit suggestions.sort((a, b) => b.confidence - a.confidence); const topSuggestions = suggestions.slice(0, maxSuggestions); // Cache the result this.suggestionCache.set(cacheKey, topSuggestions); return topSuggestions; } /** * Type-safe extraction of operation value from various formats * @param op - Operation object or string * @returns The operation value as a string */ private getOperationValue(op: any): string { if (typeof op === 'string') { return op; } if (typeof op === 'object' && op !== null) { return op.operation || op.value || ''; } return ''; } /** * Type-safe extraction of resource value * @param resource - Resource object or string * @returns The resource value as a string */ private getResourceValue(resource: any): string { if (typeof resource === 'string') { return resource; } if (typeof resource === 'object' && resource !== null) { return resource.value || ''; } return ''; } /** * Get operations for a node, handling resource filtering */ private getNodeOperations(nodeType: string, resource?: string): any[] { // Cleanup cache periodically if (Math.random() < 0.05) { // 5% chance this.cleanupExpiredEntries(); } const cacheKey = `${nodeType}:${resource || 'all'}`; const cached = this.operationCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < OperationSimilarityService.CACHE_DURATION_MS) { return cached.operations; } const nodeInfo = this.repository.getNode(nodeType); if (!nodeInfo) return []; let operations: any[] = []; // Parse operations from the node with safe JSON parsing try { const opsData = nodeInfo.operations; if (typeof opsData === 'string') { // Safe JSON parsing try { operations = JSON.parse(opsData); } catch (parseError) { logger.error(`JSON parse error for operations in ${nodeType}:`, parseError); throw ValidationServiceError.jsonParseError(nodeType, parseError as Error); } } else if (Array.isArray(opsData)) { operations = opsData; } else if (opsData && typeof opsData === 'object') { operations = Object.values(opsData).flat(); } } catch (error) { // Re-throw ValidationServiceError, log and continue for others if (error instanceof ValidationServiceError) { throw error; } logger.warn(`Failed to process operations for ${nodeType}:`, error); } // Also check properties for operation fields try { const properties = nodeInfo.properties || []; for (const prop of properties) { if (prop.name === 'operation' && prop.options) { // Filter by resource if specified if (prop.displayOptions?.show?.resource) { const allowedResources = Array.isArray(prop.displayOptions.show.resource) ? prop.displayOptions.show.resource : [prop.displayOptions.show.resource]; // Only filter if a specific resource is requested if (resource && !allowedResources.includes(resource)) { continue; } // If no resource specified, include all operations } operations.push(...prop.options.map((opt: any) => ({ operation: opt.value, name: opt.name, description: opt.description, resource }))); } } } catch (error) { logger.warn(`Failed to extract operations from properties for ${nodeType}:`, error); } // Cache and return this.operationCache.set(cacheKey, { operations, timestamp: Date.now() }); return operations; } /** * Get patterns for a specific node type */ private getNodePatterns(nodeType: string): OperationPattern[] { const patterns: OperationPattern[] = []; // Add node-specific patterns if (nodeType.includes('googleDrive')) { patterns.push(...(this.commonPatterns.get('googleDrive') || [])); } else if (nodeType.includes('slack')) { patterns.push(...(this.commonPatterns.get('slack') || [])); } else if (nodeType.includes('postgres') || nodeType.includes('mysql') || nodeType.includes('mongodb')) { patterns.push(...(this.commonPatterns.get('database') || [])); } else if (nodeType.includes('httpRequest')) { patterns.push(...(this.commonPatterns.get('httpRequest') || [])); } // Always add generic patterns patterns.push(...(this.commonPatterns.get('generic') || [])); return patterns; } /** * Calculate similarity between two strings using Levenshtein distance */ private calculateSimilarity(str1: string, str2: string): number { const s1 = str1.toLowerCase(); const s2 = str2.toLowerCase(); // Exact match if (s1 === s2) return 1.0; // One is substring of the other if (s1.includes(s2) || s2.includes(s1)) { const ratio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); return Math.max(OperationSimilarityService.CONFIDENCE_THRESHOLDS.MIN_SUBSTRING, ratio); } // Calculate Levenshtein distance const distance = this.levenshteinDistance(s1, s2); const maxLength = Math.max(s1.length, s2.length); // Convert distance to similarity (0 to 1) let similarity = 1 - (distance / maxLength); // Boost confidence for single character typos and transpositions in short words if (distance === 1 && maxLength <= 5) { similarity = Math.max(similarity, 0.75); } else if (distance === 2 && maxLength <= 5) { // Boost for transpositions similarity = Math.max(similarity, 0.72); } // Boost similarity for common patterns if (this.areCommonVariations(s1, s2)) { return Math.min(1.0, similarity + 0.2); } return similarity; } /** * Calculate Levenshtein distance between two strings */ private levenshteinDistance(str1: string, str2: string): number { const m = str1.length; const n = str2.length; const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (str1[i - 1] === str2[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = Math.min( dp[i - 1][j] + 1, // deletion dp[i][j - 1] + 1, // insertion dp[i - 1][j - 1] + 1 // substitution ); } } } return dp[m][n]; } /** * Check if two strings are common variations */ private areCommonVariations(str1: string, str2: string): boolean { // Handle edge cases first if (str1 === '' || str2 === '' || str1 === str2) { return false; } // Check for common prefixes/suffixes const commonPrefixes = ['get', 'set', 'create', 'delete', 'update', 'send', 'fetch']; const commonSuffixes = ['data', 'item', 'record', 'message', 'file', 'folder']; for (const prefix of commonPrefixes) { if ((str1.startsWith(prefix) && !str2.startsWith(prefix)) || (!str1.startsWith(prefix) && str2.startsWith(prefix))) { const s1Clean = str1.startsWith(prefix) ? str1.slice(prefix.length) : str1; const s2Clean = str2.startsWith(prefix) ? str2.slice(prefix.length) : str2; // Only return true if at least one string was actually cleaned (not empty after cleaning) if ((str1.startsWith(prefix) && s1Clean !== str1) || (str2.startsWith(prefix) && s2Clean !== str2)) { if (s1Clean === s2Clean || this.levenshteinDistance(s1Clean, s2Clean) <= 2) { return true; } } } } for (const suffix of commonSuffixes) { if ((str1.endsWith(suffix) && !str2.endsWith(suffix)) || (!str1.endsWith(suffix) && str2.endsWith(suffix))) { const s1Clean = str1.endsWith(suffix) ? str1.slice(0, -suffix.length) : str1; const s2Clean = str2.endsWith(suffix) ? str2.slice(0, -suffix.length) : str2; // Only return true if at least one string was actually cleaned (not empty after cleaning) if ((str1.endsWith(suffix) && s1Clean !== str1) || (str2.endsWith(suffix) && s2Clean !== str2)) { if (s1Clean === s2Clean || this.levenshteinDistance(s1Clean, s2Clean) <= 2) { return true; } } } } return false; } /** * Generate a human-readable reason for the similarity * @param confidence - Similarity confidence score * @param invalid - The invalid operation string * @param valid - The valid operation string * @returns Human-readable explanation of the similarity */ private getSimilarityReason(confidence: number, invalid: string, valid: string): string { const { VERY_HIGH, HIGH, MEDIUM } = OperationSimilarityService.CONFIDENCE_THRESHOLDS; if (confidence >= VERY_HIGH) { return 'Almost exact match - likely a typo'; } else if (confidence >= HIGH) { return 'Very similar - common variation'; } else if (confidence >= MEDIUM) { return 'Similar operation'; } else if (invalid.includes(valid) || valid.includes(invalid)) { return 'Partial match'; } else { return 'Possibly related operation'; } } /** * Clear caches */ clearCache(): void { this.operationCache.clear(); this.suggestionCache.clear(); } } ``` -------------------------------------------------------------------------------- /tests/integration/database/test-utils.ts: -------------------------------------------------------------------------------- ```typescript import * as fs from 'fs'; import * as path from 'path'; import Database from 'better-sqlite3'; import { execSync } from 'child_process'; import type { DatabaseAdapter } from '../../../src/database/database-adapter'; /** * Configuration options for creating test databases */ export interface TestDatabaseOptions { /** Database mode - in-memory for fast tests, file for persistence tests */ mode: 'memory' | 'file'; /** Custom database filename (only for file mode) */ name?: string; /** Enable Write-Ahead Logging for better concurrency (file mode only) */ enableWAL?: boolean; /** Enable FTS5 full-text search extension */ enableFTS5?: boolean; } /** * Test database utility for creating isolated database instances for testing. * Provides automatic schema setup, cleanup, and various helper methods. * * @example * ```typescript * // Create in-memory database for unit tests * const testDb = await TestDatabase.createIsolated({ mode: 'memory' }); * const db = testDb.getDatabase(); * // ... run tests * await testDb.cleanup(); * * // Create file-based database for integration tests * const testDb = await TestDatabase.createIsolated({ * mode: 'file', * enableWAL: true * }); * ``` */ export class TestDatabase { private db: Database.Database | null = null; private dbPath?: string; private options: TestDatabaseOptions; constructor(options: TestDatabaseOptions = { mode: 'memory' }) { this.options = options; } /** * Creates an isolated test database instance with automatic cleanup. * Each instance gets a unique name to prevent conflicts in parallel tests. * * @param options - Database configuration options * @returns Promise resolving to initialized TestDatabase instance */ static async createIsolated(options: TestDatabaseOptions = { mode: 'memory' }): Promise<TestDatabase> { const testDb = new TestDatabase({ ...options, name: options.name || `isolated-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.db` }); await testDb.initialize(); return testDb; } async initialize(): Promise<Database.Database> { if (this.db) return this.db; if (this.options.mode === 'file') { const testDir = path.join(__dirname, '../../../.test-dbs'); if (!fs.existsSync(testDir)) { fs.mkdirSync(testDir, { recursive: true }); } this.dbPath = path.join(testDir, this.options.name || `test-${Date.now()}.db`); this.db = new Database(this.dbPath); } else { this.db = new Database(':memory:'); } // Enable WAL mode for file databases if (this.options.mode === 'file' && this.options.enableWAL !== false) { this.db.exec('PRAGMA journal_mode = WAL'); } // Load FTS5 extension if requested if (this.options.enableFTS5) { // FTS5 is built into SQLite by default in better-sqlite3 try { this.db.exec('CREATE VIRTUAL TABLE test_fts USING fts5(content)'); this.db.exec('DROP TABLE test_fts'); } catch (error) { throw new Error('FTS5 extension not available'); } } // Apply schema await this.applySchema(); return this.db; } private async applySchema(): Promise<void> { if (!this.db) throw new Error('Database not initialized'); const schemaPath = path.join(__dirname, '../../../src/database/schema.sql'); const schema = fs.readFileSync(schemaPath, 'utf-8'); // Parse SQL statements properly (handles BEGIN...END blocks in triggers) const statements = this.parseSQLStatements(schema); for (const statement of statements) { this.db.exec(statement); } } /** * Parse SQL statements from schema file, properly handling multi-line statements * including triggers with BEGIN...END blocks */ private parseSQLStatements(sql: string): string[] { const statements: string[] = []; let current = ''; let inBlock = false; const lines = sql.split('\n'); for (const line of lines) { const trimmed = line.trim().toUpperCase(); // Skip comments and empty lines if (trimmed.startsWith('--') || trimmed === '') { continue; } // Track BEGIN...END blocks (triggers, procedures) if (trimmed.includes('BEGIN')) { inBlock = true; } current += line + '\n'; // End of block (trigger/procedure) if (inBlock && trimmed === 'END;') { statements.push(current.trim()); current = ''; inBlock = false; continue; } // Regular statement end (not in block) if (!inBlock && trimmed.endsWith(';')) { statements.push(current.trim()); current = ''; } } // Add any remaining content if (current.trim()) { statements.push(current.trim()); } return statements.filter(s => s.length > 0); } /** * Gets the underlying better-sqlite3 database instance. * @throws Error if database is not initialized * @returns The database instance */ getDatabase(): Database.Database { if (!this.db) throw new Error('Database not initialized'); return this.db; } /** * Cleans up the database connection and removes any created files. * Should be called in afterEach/afterAll hooks to prevent resource leaks. */ async cleanup(): Promise<void> { if (this.db) { this.db.close(); this.db = null; } if (this.dbPath && fs.existsSync(this.dbPath)) { fs.unlinkSync(this.dbPath); // Also remove WAL and SHM files if they exist const walPath = `${this.dbPath}-wal`; const shmPath = `${this.dbPath}-shm`; if (fs.existsSync(walPath)) fs.unlinkSync(walPath); if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath); } } /** * Checks if the database is currently locked by another process. * Useful for testing concurrent access scenarios. * * @returns true if database is locked, false otherwise */ isLocked(): boolean { if (!this.db) return false; try { this.db.exec('BEGIN IMMEDIATE'); this.db.exec('ROLLBACK'); return false; } catch (error: any) { return error.code === 'SQLITE_BUSY'; } } } /** * Performance monitoring utility for measuring test execution times. * Collects timing data and provides statistical analysis. * * @example * ```typescript * const monitor = new PerformanceMonitor(); * * // Measure single operation * const stop = monitor.start('database-query'); * await db.query('SELECT * FROM nodes'); * stop(); * * // Get statistics * const stats = monitor.getStats('database-query'); * console.log(`Average: ${stats.average}ms`); * ``` */ export class PerformanceMonitor { private measurements: Map<string, number[]> = new Map(); /** * Starts timing for a labeled operation. * Returns a function that should be called to stop timing. * * @param label - Unique label for the operation being measured * @returns Stop function to call when operation completes */ start(label: string): () => void { const startTime = process.hrtime.bigint(); return () => { const endTime = process.hrtime.bigint(); const duration = Number(endTime - startTime) / 1_000_000; // Convert to milliseconds if (!this.measurements.has(label)) { this.measurements.set(label, []); } this.measurements.get(label)!.push(duration); }; } /** * Gets statistical analysis of all measurements for a given label. * * @param label - The operation label to get stats for * @returns Statistics object or null if no measurements exist */ getStats(label: string): { count: number; total: number; average: number; min: number; max: number; median: number; } | null { const durations = this.measurements.get(label); if (!durations || durations.length === 0) return null; const sorted = [...durations].sort((a, b) => a - b); const total = durations.reduce((sum, d) => sum + d, 0); return { count: durations.length, total, average: total / durations.length, min: sorted[0], max: sorted[sorted.length - 1], median: sorted[Math.floor(sorted.length / 2)] }; } /** * Clears all collected measurements. */ clear(): void { this.measurements.clear(); } } /** * Test data generator for creating mock nodes, templates, and other test objects. * Provides consistent test data with sensible defaults and easy customization. */ export class TestDataGenerator { /** * Generates a mock node object with default values and custom overrides. * * @param overrides - Properties to override in the generated node * @returns Complete node object suitable for testing * * @example * ```typescript * const node = TestDataGenerator.generateNode({ * displayName: 'Custom Node', * isAITool: true * }); * ``` */ static generateNode(overrides: any = {}): any { const nodeName = overrides.name || `testNode${Math.random().toString(36).substr(2, 9)}`; return { nodeType: overrides.nodeType || `n8n-nodes-base.${nodeName}`, packageName: overrides.packageName || overrides.package || 'n8n-nodes-base', displayName: overrides.displayName || 'Test Node', description: overrides.description || 'A test node for integration testing', category: overrides.category || 'automation', developmentStyle: overrides.developmentStyle || overrides.style || 'programmatic', isAITool: overrides.isAITool || false, isTrigger: overrides.isTrigger || false, isWebhook: overrides.isWebhook || false, isVersioned: overrides.isVersioned !== undefined ? overrides.isVersioned : true, version: overrides.version || '1', documentation: overrides.documentation || null, properties: overrides.properties || [], operations: overrides.operations || [], credentials: overrides.credentials || [], ...overrides }; } /** * Generates multiple nodes with sequential naming. * * @param count - Number of nodes to generate * @param template - Common properties to apply to all nodes * @returns Array of generated nodes */ static generateNodes(count: number, template: any = {}): any[] { return Array.from({ length: count }, (_, i) => this.generateNode({ ...template, name: `testNode${i}`, displayName: `Test Node ${i}`, nodeType: `n8n-nodes-base.testNode${i}` }) ); } /** * Generates a mock workflow template. * * @param overrides - Properties to override in the template * @returns Template object suitable for testing */ static generateTemplate(overrides: any = {}): any { return { id: Math.floor(Math.random() * 100000), name: `Test Workflow ${Math.random().toString(36).substr(2, 9)}`, totalViews: Math.floor(Math.random() * 1000), nodeTypes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'], categories: [{ id: 1, name: 'automation' }], description: 'A test workflow template', workflowInfo: { nodeCount: 5, webhookCount: 1 }, ...overrides }; } /** * Generates multiple workflow templates. * * @param count - Number of templates to generate * @returns Array of template objects */ static generateTemplates(count: number): any[] { return Array.from({ length: count }, () => this.generateTemplate()); } } /** * Runs a function within a database transaction with automatic rollback on error. * Useful for testing transactional behavior and ensuring test isolation. * * @param db - Database instance * @param fn - Function to run within transaction * @returns Promise resolving to function result * @throws Rolls back transaction and rethrows any errors * * @example * ```typescript * await runInTransaction(db, () => { * db.prepare('INSERT INTO nodes ...').run(); * db.prepare('UPDATE nodes ...').run(); * // If any operation fails, all are rolled back * }); * ``` */ export async function runInTransaction<T>( db: Database.Database, fn: () => T ): Promise<T> { db.exec('BEGIN'); try { const result = await fn(); db.exec('COMMIT'); return result; } catch (error) { db.exec('ROLLBACK'); throw error; } } /** * Simulates concurrent database access using worker processes. * Useful for testing database locking and concurrency handling. * * @param dbPath - Path to the database file * @param workerCount - Number of concurrent workers to spawn * @param operations - Number of operations each worker should perform * @param workerScript - JavaScript code to execute in each worker * @returns Results with success/failure counts and total duration * * @example * ```typescript * const results = await simulateConcurrentAccess( * dbPath, * 10, // 10 workers * 100, // 100 operations each * ` * const db = require('better-sqlite3')(process.env.DB_PATH); * for (let i = 0; i < process.env.OPERATIONS; i++) { * db.prepare('INSERT INTO test VALUES (?)').run(i); * } * ` * ); * ``` */ export async function simulateConcurrentAccess( dbPath: string, workerCount: number, operations: number, workerScript: string ): Promise<{ success: number; failed: number; duration: number }> { const startTime = Date.now(); const results = { success: 0, failed: 0 }; // Create worker processes const workers = Array.from({ length: workerCount }, (_, i) => { return new Promise<void>((resolve) => { try { const output = execSync( `node -e "${workerScript}"`, { env: { ...process.env, DB_PATH: dbPath, WORKER_ID: i.toString(), OPERATIONS: operations.toString() } } ); results.success++; } catch (error) { results.failed++; } resolve(); }); }); await Promise.all(workers); return { ...results, duration: Date.now() - startTime }; } /** * Performs comprehensive database integrity checks including foreign keys and schema. * * @param db - Database instance to check * @returns Object with validation status and any error messages * * @example * ```typescript * const integrity = checkDatabaseIntegrity(db); * if (!integrity.isValid) { * console.error('Database issues:', integrity.errors); * } * ``` */ export function checkDatabaseIntegrity(db: Database.Database): { isValid: boolean; errors: string[]; } { const errors: string[] = []; try { // Run integrity check const result = db.prepare('PRAGMA integrity_check').all() as Array<{ integrity_check: string }>; if (result.length !== 1 || result[0].integrity_check !== 'ok') { errors.push('Database integrity check failed'); } // Check foreign key constraints const fkResult = db.prepare('PRAGMA foreign_key_check').all(); if (fkResult.length > 0) { errors.push(`Foreign key violations: ${JSON.stringify(fkResult)}`); } // Check table existence const tables = db.prepare(` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'nodes' `).all(); if (tables.length === 0) { errors.push('nodes table does not exist'); } } catch (error: any) { errors.push(`Integrity check error: ${error.message}`); } return { isValid: errors.length === 0, errors }; } /** * Creates a DatabaseAdapter interface from a better-sqlite3 instance. * This adapter provides a consistent interface for database operations across the codebase. * * @param db - better-sqlite3 database instance * @returns DatabaseAdapter implementation * * @example * ```typescript * const db = new Database(':memory:'); * const adapter = createTestDatabaseAdapter(db); * const stmt = adapter.prepare('SELECT * FROM nodes WHERE type = ?'); * const nodes = stmt.all('webhook'); * ``` */ export function createTestDatabaseAdapter(db: Database.Database): DatabaseAdapter { return { prepare: (sql: string) => { const stmt = db.prepare(sql); return { run: (...params: any[]) => stmt.run(...params), get: (...params: any[]) => stmt.get(...params), all: (...params: any[]) => stmt.all(...params), iterate: (...params: any[]) => stmt.iterate(...params), pluck: function(enabled?: boolean) { stmt.pluck(enabled); return this; }, expand: function(enabled?: boolean) { stmt.expand?.(enabled); return this; }, raw: function(enabled?: boolean) { stmt.raw?.(enabled); return this; }, columns: () => stmt.columns?.() || [], bind: function(...params: any[]) { stmt.bind(...params); return this; } } as any; }, exec: (sql: string) => db.exec(sql), close: () => db.close(), pragma: (key: string, value?: any) => db.pragma(key, value), get inTransaction() { return db.inTransaction; }, transaction: <T>(fn: () => T) => db.transaction(fn)(), checkFTS5Support: () => { try { db.exec('CREATE VIRTUAL TABLE test_fts5_check USING fts5(content)'); db.exec('DROP TABLE test_fts5_check'); return true; } catch { return false; } } }; } /** * Pre-configured mock nodes for common testing scenarios. * These represent the most commonly used n8n nodes with realistic configurations. */ export const MOCK_NODES = { webhook: { nodeType: 'n8n-nodes-base.webhook', packageName: 'n8n-nodes-base', displayName: 'Webhook', description: 'Starts the workflow when a webhook is called', category: 'trigger', developmentStyle: 'programmatic', isAITool: false, isTrigger: true, isWebhook: true, isVersioned: true, version: '1', documentation: 'Webhook documentation', properties: [ { displayName: 'HTTP Method', name: 'httpMethod', type: 'options', options: [ { name: 'GET', value: 'GET' }, { name: 'POST', value: 'POST' } ], default: 'GET' } ], operations: [], credentials: [] }, httpRequest: { nodeType: 'n8n-nodes-base.httpRequest', packageName: 'n8n-nodes-base', displayName: 'HTTP Request', description: 'Makes an HTTP request and returns the response', category: 'automation', developmentStyle: 'programmatic', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1', documentation: 'HTTP Request documentation', properties: [ { displayName: 'URL', name: 'url', type: 'string', required: true, default: '' } ], operations: [], credentials: [] } }; ``` -------------------------------------------------------------------------------- /src/http-server.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * Fixed HTTP server for n8n-MCP that properly handles StreamableHTTPServerTransport initialization * This implementation ensures the transport is properly initialized before handling requests */ import express from 'express'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { n8nDocumentationToolsFinal } from './mcp/tools'; import { n8nManagementTools } from './mcp/tools-n8n-manager'; import { N8NDocumentationMCPServer } from './mcp/server'; import { logger } from './utils/logger'; import { AuthManager } from './utils/auth'; import { PROJECT_VERSION } from './utils/version'; import { isN8nApiConfigured } from './config/n8n-api'; import dotenv from 'dotenv'; import { readFileSync } from 'fs'; import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector'; import { negotiateProtocolVersion, logProtocolNegotiation, N8N_PROTOCOL_VERSION } from './utils/protocol-version'; dotenv.config(); let expressServer: any; let authToken: string | null = null; /** * Load auth token from environment variable or file */ export function loadAuthToken(): string | null { // First, try AUTH_TOKEN environment variable if (process.env.AUTH_TOKEN) { logger.info('Using AUTH_TOKEN from environment variable'); return process.env.AUTH_TOKEN; } // Then, try AUTH_TOKEN_FILE if (process.env.AUTH_TOKEN_FILE) { try { const token = readFileSync(process.env.AUTH_TOKEN_FILE, 'utf-8').trim(); logger.info(`Loaded AUTH_TOKEN from file: ${process.env.AUTH_TOKEN_FILE}`); return token; } catch (error) { logger.error(`Failed to read AUTH_TOKEN_FILE: ${process.env.AUTH_TOKEN_FILE}`, error); console.error(`ERROR: Failed to read AUTH_TOKEN_FILE: ${process.env.AUTH_TOKEN_FILE}`); console.error(error instanceof Error ? error.message : 'Unknown error'); return null; } } return null; } /** * Validate required environment variables */ function validateEnvironment() { // Load auth token from env var or file authToken = loadAuthToken(); if (!authToken || authToken.trim() === '') { logger.error('No authentication token found or token is empty'); console.error('ERROR: AUTH_TOKEN is required for HTTP mode and cannot be empty'); console.error('Set AUTH_TOKEN environment variable or AUTH_TOKEN_FILE pointing to a file containing the token'); console.error('Generate AUTH_TOKEN with: openssl rand -base64 32'); process.exit(1); } // Update authToken to trimmed version authToken = authToken.trim(); if (authToken.length < 32) { logger.warn('AUTH_TOKEN should be at least 32 characters for security'); console.warn('WARNING: AUTH_TOKEN should be at least 32 characters for security'); } // Check for default token and show prominent warnings if (authToken === 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh') { logger.warn('⚠️ SECURITY WARNING: Using default AUTH_TOKEN - CHANGE IMMEDIATELY!'); logger.warn('Generate secure token with: openssl rand -base64 32'); // Only show console warnings in HTTP mode if (process.env.MCP_MODE === 'http') { console.warn('\n⚠️ SECURITY WARNING ⚠️'); console.warn('Using default AUTH_TOKEN - CHANGE IMMEDIATELY!'); console.warn('Generate secure token: openssl rand -base64 32'); console.warn('Update via Railway dashboard environment variables\n'); } } } /** * Graceful shutdown handler */ async function shutdown() { logger.info('Shutting down HTTP server...'); console.log('Shutting down HTTP server...'); if (expressServer) { expressServer.close(() => { logger.info('HTTP server closed'); console.log('HTTP server closed'); process.exit(0); }); setTimeout(() => { logger.error('Forced shutdown after timeout'); process.exit(1); }, 10000); } else { process.exit(0); } } export async function startFixedHTTPServer() { validateEnvironment(); const app = express(); // Configure trust proxy for correct IP logging behind reverse proxies const trustProxy = process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : 0; if (trustProxy > 0) { app.set('trust proxy', trustProxy); logger.info(`Trust proxy enabled with ${trustProxy} hop(s)`); } // CRITICAL: Don't use any body parser - StreamableHTTPServerTransport needs raw stream // Security headers app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); next(); }); // CORS configuration app.use((req, res, next) => { const allowedOrigin = process.env.CORS_ORIGIN || '*'; res.setHeader('Access-Control-Allow-Origin', allowedOrigin); res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept'); res.setHeader('Access-Control-Max-Age', '86400'); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }); // Request logging app.use((req, res, next) => { logger.info(`${req.method} ${req.path}`, { ip: req.ip, userAgent: req.get('user-agent'), contentLength: req.get('content-length') }); next(); }); // Create a single persistent MCP server instance const mcpServer = new N8NDocumentationMCPServer(); logger.info('Created persistent MCP server instance'); // Root endpoint with API information app.get('/', (req, res) => { const port = parseInt(process.env.PORT || '3000'); const host = process.env.HOST || '0.0.0.0'; const baseUrl = detectBaseUrl(req, host, port); const endpoints = formatEndpointUrls(baseUrl); res.json({ name: 'n8n Documentation MCP Server', version: PROJECT_VERSION, description: 'Model Context Protocol server providing comprehensive n8n node documentation and workflow management', endpoints: { health: { url: endpoints.health, method: 'GET', description: 'Health check and status information' }, mcp: { url: endpoints.mcp, method: 'GET/POST', description: 'MCP endpoint - GET for info, POST for JSON-RPC' } }, authentication: { type: 'Bearer Token', header: 'Authorization: Bearer <token>', required_for: ['POST /mcp'] }, documentation: 'https://github.com/czlonkowski/n8n-mcp' }); }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', mode: 'http-fixed', version: PROJECT_VERSION, uptime: Math.floor(process.uptime()), memory: { used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), unit: 'MB' }, timestamp: new Date().toISOString() }); }); // Version endpoint app.get('/version', (req, res) => { res.json({ version: PROJECT_VERSION, buildTime: new Date().toISOString(), tools: n8nDocumentationToolsFinal.map(t => t.name), commit: process.env.GIT_COMMIT || 'unknown' }); }); // Test tools endpoint app.get('/test-tools', async (req, res) => { try { const result = await mcpServer.executeTool('get_node_essentials', { nodeType: 'nodes-base.httpRequest' }); res.json({ status: 'ok', hasData: !!result, toolCount: n8nDocumentationToolsFinal.length }); } catch (error) { res.json({ status: 'error', message: error instanceof Error ? error.message : 'Unknown error' }); } }); // MCP information endpoint (no auth required for discovery) app.get('/mcp', (req, res) => { res.json({ description: 'n8n Documentation MCP Server', version: PROJECT_VERSION, endpoints: { mcp: { method: 'POST', path: '/mcp', description: 'Main MCP JSON-RPC endpoint', authentication: 'Bearer token required' }, health: { method: 'GET', path: '/health', description: 'Health check endpoint', authentication: 'None' }, root: { method: 'GET', path: '/', description: 'API information', authentication: 'None' } }, documentation: 'https://github.com/czlonkowski/n8n-mcp' }); }); // Main MCP endpoint - handle each request with custom transport handling app.post('/mcp', async (req: express.Request, res: express.Response): Promise<void> => { const startTime = Date.now(); // Enhanced authentication check with specific logging const authHeader = req.headers.authorization; // Check if Authorization header is missing if (!authHeader) { logger.warn('Authentication failed: Missing Authorization header', { ip: req.ip, userAgent: req.get('user-agent'), reason: 'no_auth_header' }); res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null }); return; } // Check if Authorization header has Bearer prefix if (!authHeader.startsWith('Bearer ')) { logger.warn('Authentication failed: Invalid Authorization header format (expected Bearer token)', { ip: req.ip, userAgent: req.get('user-agent'), reason: 'invalid_auth_format', headerPrefix: authHeader.substring(0, Math.min(authHeader.length, 10)) + '...' // Log first 10 chars for debugging }); res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null }); return; } // Extract token and trim whitespace const token = authHeader.slice(7).trim(); // SECURITY: Use timing-safe comparison to prevent timing attacks // See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02) const isValidToken = authToken && AuthManager.timingSafeCompare(token, authToken); if (!isValidToken) { logger.warn('Authentication failed: Invalid token', { ip: req.ip, userAgent: req.get('user-agent'), reason: 'invalid_token' }); res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null }); return; } try { // Instead of using StreamableHTTPServerTransport, we'll handle the request directly // This avoids the initialization issues with the transport // Collect the raw body let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { const jsonRpcRequest = JSON.parse(body); logger.debug('Received JSON-RPC request:', { method: jsonRpcRequest.method }); // Handle the request based on method let response; switch (jsonRpcRequest.method) { case 'initialize': // Negotiate protocol version for this client/request const negotiationResult = negotiateProtocolVersion( jsonRpcRequest.params?.protocolVersion, jsonRpcRequest.params?.clientInfo, req.get('user-agent'), req.headers ); logProtocolNegotiation(negotiationResult, logger, 'HTTP_SERVER_INITIALIZE'); response = { jsonrpc: '2.0', result: { protocolVersion: negotiationResult.version, capabilities: { tools: {}, resources: {} }, serverInfo: { name: 'n8n-documentation-mcp', version: PROJECT_VERSION } }, id: jsonRpcRequest.id }; break; case 'tools/list': // Use the proper tool list that includes management tools when configured const tools = [...n8nDocumentationToolsFinal]; // Add management tools if n8n API is configured if (isN8nApiConfigured()) { tools.push(...n8nManagementTools); } response = { jsonrpc: '2.0', result: { tools }, id: jsonRpcRequest.id }; break; case 'tools/call': // Delegate to the MCP server const toolName = jsonRpcRequest.params?.name; const toolArgs = jsonRpcRequest.params?.arguments || {}; try { const result = await mcpServer.executeTool(toolName, toolArgs); response = { jsonrpc: '2.0', result: { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ] }, id: jsonRpcRequest.id }; } catch (error) { response = { jsonrpc: '2.0', error: { code: -32603, message: `Error executing tool ${toolName}: ${error instanceof Error ? error.message : 'Unknown error'}` }, id: jsonRpcRequest.id }; } break; default: response = { jsonrpc: '2.0', error: { code: -32601, message: `Method not found: ${jsonRpcRequest.method}` }, id: jsonRpcRequest.id }; } // Send response res.setHeader('Content-Type', 'application/json'); res.json(response); const duration = Date.now() - startTime; logger.info('MCP request completed', { duration, method: jsonRpcRequest.method }); } catch (error) { logger.error('Error processing request:', error); res.status(400).json({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error', data: error instanceof Error ? error.message : 'Unknown error' }, id: null }); } }); } catch (error) { logger.error('MCP request error:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', data: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined }, id: null }); } } }); // 404 handler app.use((req, res) => { res.status(404).json({ error: 'Not found', message: `Cannot ${req.method} ${req.path}` }); }); // Error handler app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { logger.error('Express error handler:', err); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', data: process.env.NODE_ENV === 'development' ? err.message : undefined }, id: null }); } }); const port = parseInt(process.env.PORT || '3000'); const host = process.env.HOST || '0.0.0.0'; expressServer = app.listen(port, host, () => { logger.info(`n8n MCP Fixed HTTP Server started`, { port, host }); // Detect the base URL using our utility const baseUrl = getStartupBaseUrl(host, port); const endpoints = formatEndpointUrls(baseUrl); console.log(`n8n MCP Fixed HTTP Server running on ${host}:${port}`); console.log(`Health check: ${endpoints.health}`); console.log(`MCP endpoint: ${endpoints.mcp}`); console.log('\nPress Ctrl+C to stop the server'); // Start periodic warning timer if using default token if (authToken === 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh') { setInterval(() => { logger.warn('⚠️ Still using default AUTH_TOKEN - security risk!'); if (process.env.MCP_MODE === 'http') { console.warn('⚠️ REMINDER: Still using default AUTH_TOKEN - please change it!'); } }, 300000); // Every 5 minutes } if (process.env.BASE_URL || process.env.PUBLIC_URL) { console.log(`\nPublic URL configured: ${baseUrl}`); } else if (process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) { console.log(`\nNote: TRUST_PROXY is enabled. URLs will be auto-detected from proxy headers.`); } }); // Handle errors expressServer.on('error', (error: any) => { if (error.code === 'EADDRINUSE') { logger.error(`Port ${port} is already in use`); console.error(`ERROR: Port ${port} is already in use`); process.exit(1); } else { logger.error('Server error:', error); console.error('Server error:', error); process.exit(1); } }); // Graceful shutdown handlers process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); // Handle uncaught errors process.on('uncaughtException', (error) => { logger.error('Uncaught exception:', error); console.error('Uncaught exception:', error); shutdown(); }); process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled rejection:', reason); console.error('Unhandled rejection at:', promise, 'reason:', reason); shutdown(); }); } // Make executeTool public on the server declare module './mcp/server' { interface N8NDocumentationMCPServer { executeTool(name: string, args: any): Promise<any>; } } // Start if called directly // Check if this file is being run directly (not imported) // In ES modules, we check import.meta.url against process.argv[1] // But since we're transpiling to CommonJS, we use the require.main check if (typeof require !== 'undefined' && require.main === module) { startFixedHTTPServer().catch(error => { logger.error('Failed to start Fixed HTTP server:', error); console.error('Failed to start Fixed HTTP server:', error); process.exit(1); }); } ```