This is page 18 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 -------------------------------------------------------------------------------- /docs/workflow-diff-examples.md: -------------------------------------------------------------------------------- ```markdown # Workflow Diff Examples This guide demonstrates how to use the `n8n_update_partial_workflow` tool for efficient workflow editing. ## Overview The `n8n_update_partial_workflow` tool allows you to make targeted changes to workflows without sending the entire workflow JSON. This results in: - 80-90% reduction in token usage - More precise edits - Clearer intent - Reduced risk of accidentally modifying unrelated parts ## Basic Usage ```json { "id": "workflow-id-here", "operations": [ { "type": "operation-type", "...operation-specific-fields..." } ] } ``` ## Operation Types ### 1. Node Operations #### Add Node ```json { "type": "addNode", "description": "Add HTTP Request node to fetch data", "node": { "name": "Fetch User Data", "type": "n8n-nodes-base.httpRequest", "position": [600, 300], "parameters": { "url": "https://api.example.com/users", "method": "GET", "authentication": "none" } } } ``` #### Remove Node ```json { "type": "removeNode", "nodeName": "Old Node Name", "description": "Remove deprecated node" } ``` #### Update Node ```json { "type": "updateNode", "nodeName": "HTTP Request", "changes": { "parameters.url": "https://new-api.example.com/v2/users", "parameters.headers.parameters": [ { "name": "Authorization", "value": "Bearer {{$credentials.apiKey}}" } ] }, "description": "Update API endpoint to v2" } ``` #### Move Node ```json { "type": "moveNode", "nodeName": "Set Variable", "position": [800, 400], "description": "Reposition for better layout" } ``` #### Enable/Disable Node ```json { "type": "disableNode", "nodeName": "Debug Node", "description": "Disable debug output for production" } ``` ### 2. Connection Operations #### Add Connection ```json { "type": "addConnection", "source": "Webhook", "target": "Process Data", "sourceOutput": "main", "targetInput": "main", "description": "Connect webhook to processor" } ``` #### Remove Connection ```json { "type": "removeConnection", "source": "Old Source", "target": "Old Target", "description": "Remove unused connection" } ``` #### Rewire Connection ```json { "type": "rewireConnection", "source": "Webhook", "from": "Old Handler", "to": "New Handler", "description": "Rewire connection to new handler" } ``` #### Smart Parameters for IF Nodes ```json { "type": "addConnection", "source": "IF", "target": "Success Handler", "branch": "true", // Semantic parameter instead of sourceIndex "description": "Route true branch to success handler" } ``` ```json { "type": "addConnection", "source": "IF", "target": "Error Handler", "branch": "false", // Routes to false branch (sourceIndex=1) "description": "Route false branch to error handler" } ``` #### Smart Parameters for Switch Nodes ```json { "type": "addConnection", "source": "Switch", "target": "Handler A", "case": 0, // First output "description": "Route case 0 to Handler A" } ``` ### 3. Workflow Metadata Operations #### Update Workflow Name ```json { "type": "updateName", "name": "Production User Sync v2", "description": "Update workflow name for versioning" } ``` #### Update Settings ```json { "type": "updateSettings", "settings": { "executionTimeout": 300, "saveDataErrorExecution": "all", "timezone": "America/New_York" }, "description": "Configure production settings" } ``` #### Manage Tags ```json { "type": "addTag", "tag": "production", "description": "Mark as production workflow" } ``` ## Complete Examples ### Example 1: Add Slack Notification to Workflow ```json { "id": "workflow-123", "operations": [ { "type": "addNode", "node": { "name": "Send Slack Alert", "type": "n8n-nodes-base.slack", "position": [1000, 300], "parameters": { "resource": "message", "operation": "post", "channel": "#alerts", "text": "Workflow completed successfully!" } } }, { "type": "addConnection", "source": "Process Data", "target": "Send Slack Alert" } ] } ``` ### Example 2: Update Multiple Webhook Paths ```json { "id": "workflow-456", "operations": [ { "type": "updateNode", "nodeName": "Webhook 1", "changes": { "parameters.path": "v2/webhook1" } }, { "type": "updateNode", "nodeName": "Webhook 2", "changes": { "parameters.path": "v2/webhook2" } }, { "type": "updateName", "name": "API v2 Webhooks" } ] } ``` ### Example 3: Refactor Workflow Structure ```json { "id": "workflow-789", "operations": [ { "type": "removeNode", "nodeName": "Legacy Processor" }, { "type": "addNode", "node": { "name": "Modern Processor", "type": "n8n-nodes-base.code", "position": [600, 300], "parameters": { "mode": "runOnceForEachItem", "jsCode": "// Process items\nreturn item;" } } }, { "type": "addConnection", "source": "HTTP Request", "target": "Modern Processor" }, { "type": "addConnection", "source": "Modern Processor", "target": "Save to Database" } ] } ``` ### Example 4: Add Error Handling ```json { "id": "workflow-999", "operations": [ { "type": "addNode", "node": { "name": "Error Handler", "type": "n8n-nodes-base.errorTrigger", "position": [200, 500] } }, { "type": "addNode", "node": { "name": "Send Error Email", "type": "n8n-nodes-base.emailSend", "position": [400, 500], "parameters": { "toEmail": "[email protected]", "subject": "Workflow Error: {{$node['Error Handler'].json.error.message}}", "text": "Error details: {{$json}}" } } }, { "type": "addConnection", "source": "Error Handler", "target": "Send Error Email" }, { "type": "updateSettings", "settings": { "errorWorkflow": "workflow-999" } } ] } ``` ### Example 5: Large Batch Workflow Refactoring Demonstrates handling many operations in a single request - no longer limited to 5 operations! ```json { "id": "workflow-batch", "operations": [ // Add 10 processing nodes { "type": "addNode", "node": { "name": "Filter Active Users", "type": "n8n-nodes-base.filter", "position": [400, 200], "parameters": { "conditions": { "boolean": [{ "value1": "={{$json.active}}", "value2": true }] } } } }, { "type": "addNode", "node": { "name": "Transform User Data", "type": "n8n-nodes-base.set", "position": [600, 200], "parameters": { "values": { "string": [{ "name": "formatted_name", "value": "={{$json.firstName}} {{$json.lastName}}" }] } } } }, { "type": "addNode", "node": { "name": "Validate Email", "type": "n8n-nodes-base.if", "position": [800, 200], "parameters": { "conditions": { "string": [{ "value1": "={{$json.email}}", "operation": "contains", "value2": "@" }] } } } }, { "type": "addNode", "node": { "name": "Enrich with API", "type": "n8n-nodes-base.httpRequest", "position": [1000, 150], "parameters": { "url": "https://api.example.com/enrich", "method": "POST" } } }, { "type": "addNode", "node": { "name": "Log Invalid Emails", "type": "n8n-nodes-base.code", "position": [1000, 350], "parameters": { "jsCode": "console.log('Invalid email:', $json.email);\nreturn $json;" } } }, { "type": "addNode", "node": { "name": "Merge Results", "type": "n8n-nodes-base.merge", "position": [1200, 250] } }, { "type": "addNode", "node": { "name": "Deduplicate", "type": "n8n-nodes-base.removeDuplicates", "position": [1400, 250], "parameters": { "propertyName": "id" } } }, { "type": "addNode", "node": { "name": "Sort by Date", "type": "n8n-nodes-base.sort", "position": [1600, 250], "parameters": { "sortFieldsUi": { "sortField": [{ "fieldName": "created_at", "order": "descending" }] } } } }, { "type": "addNode", "node": { "name": "Batch for DB", "type": "n8n-nodes-base.splitInBatches", "position": [1800, 250], "parameters": { "batchSize": 100 } } }, { "type": "addNode", "node": { "name": "Save to Database", "type": "n8n-nodes-base.postgres", "position": [2000, 250], "parameters": { "operation": "insert", "table": "processed_users" } } }, // Connect all the nodes { "type": "addConnection", "source": "Get Users", "target": "Filter Active Users" }, { "type": "addConnection", "source": "Filter Active Users", "target": "Transform User Data" }, { "type": "addConnection", "source": "Transform User Data", "target": "Validate Email" }, { "type": "addConnection", "source": "Validate Email", "sourceOutput": "true", "target": "Enrich with API" }, { "type": "addConnection", "source": "Validate Email", "sourceOutput": "false", "target": "Log Invalid Emails" }, { "type": "addConnection", "source": "Enrich with API", "target": "Merge Results" }, { "type": "addConnection", "source": "Log Invalid Emails", "target": "Merge Results", "targetInput": "input2" }, { "type": "addConnection", "source": "Merge Results", "target": "Deduplicate" }, { "type": "addConnection", "source": "Deduplicate", "target": "Sort by Date" }, { "type": "addConnection", "source": "Sort by Date", "target": "Batch for DB" }, { "type": "addConnection", "source": "Batch for DB", "target": "Save to Database" }, // Update workflow metadata { "type": "updateName", "name": "User Processing Pipeline v2" }, { "type": "updateSettings", "settings": { "executionOrder": "v1", "timezone": "UTC", "saveDataSuccessExecution": "all" } }, { "type": "addTag", "tag": "production" }, { "type": "addTag", "tag": "user-processing" }, { "type": "addTag", "tag": "v2" } ] } ``` This example shows 26 operations in a single request, creating a complete data processing pipeline with proper error handling, validation, and batch processing. ## Best Practices 1. **Use Descriptive Names**: Always provide clear node names and descriptions for operations 2. **Batch Related Changes**: Group related operations in a single request 3. **Validate First**: Use `validateOnly: true` to test your operations before applying 4. **Reference by Name**: Prefer node names over IDs for better readability 5. **Small, Focused Changes**: Make targeted edits rather than large structural changes ## Common Patterns ### Add Processing Step ```json { "operations": [ { "type": "removeConnection", "source": "Source Node", "target": "Target Node" }, { "type": "addNode", "node": { "name": "Process Step", "type": "n8n-nodes-base.set", "position": [600, 300], "parameters": { /* ... */ } } }, { "type": "addConnection", "source": "Source Node", "target": "Process Step" }, { "type": "addConnection", "source": "Process Step", "target": "Target Node" } ] } ``` ### Replace Node ```json { "operations": [ { "type": "addNode", "node": { "name": "New Implementation", "type": "n8n-nodes-base.httpRequest", "position": [600, 300], "parameters": { /* ... */ } } }, { "type": "removeConnection", "source": "Previous Node", "target": "Old Implementation" }, { "type": "removeConnection", "source": "Old Implementation", "target": "Next Node" }, { "type": "addConnection", "source": "Previous Node", "target": "New Implementation" }, { "type": "addConnection", "source": "New Implementation", "target": "Next Node" }, { "type": "removeNode", "nodeName": "Old Implementation" } ] } ``` ## Error Handling The tool validates all operations before applying any changes. Common errors include: - **Duplicate node names**: Each node must have a unique name - **Invalid node types**: Use full package prefixes (e.g., `n8n-nodes-base.webhook`) - **Missing connections**: Referenced nodes must exist - **Circular dependencies**: Connections cannot create loops Always check the response for validation errors and adjust your operations accordingly. ## Transactional Updates The diff engine now supports transactional updates using a **two-pass processing** approach: ### How It Works 1. **No Operation Limit**: Process unlimited operations in a single request 2. **Two-Pass Processing**: - **Pass 1**: All node operations (add, remove, update, move, enable, disable) - **Pass 2**: All other operations (connections, settings, metadata) This allows you to add nodes and connect them in the same request: ```json { "id": "workflow-id", "operations": [ // These will be processed in Pass 2 (but work because nodes are added first) { "type": "addConnection", "source": "Webhook", "target": "Process Data" }, { "type": "addConnection", "source": "Process Data", "target": "Send Email" }, // These will be processed in Pass 1 { "type": "addNode", "node": { "name": "Process Data", "type": "n8n-nodes-base.set", "position": [400, 300], "parameters": {} } }, { "type": "addNode", "node": { "name": "Send Email", "type": "n8n-nodes-base.emailSend", "position": [600, 300], "parameters": { "to": "[email protected]" } } } ] } ``` ### Benefits - **Order Independence**: You don't need to worry about operation order - **Atomic Updates**: All operations succeed or all fail (unless continueOnError is enabled) - **Intuitive Usage**: Add complex workflow structures in one call - **No Hard Limits**: Process unlimited operations efficiently ### Example: Complete Workflow Addition ```json { "id": "workflow-id", "operations": [ // Add three nodes { "type": "addNode", "node": { "name": "Schedule", "type": "n8n-nodes-base.schedule", "position": [200, 300], "parameters": { "rule": { "interval": [{ "field": "hours", "intervalValue": 1 }] } } } }, { "type": "addNode", "node": { "name": "Get Data", "type": "n8n-nodes-base.httpRequest", "position": [400, 300], "parameters": { "url": "https://api.example.com/data" } } }, { "type": "addNode", "node": { "name": "Save to Database", "type": "n8n-nodes-base.postgres", "position": [600, 300], "parameters": { "operation": "insert" } } }, // Connect them all { "type": "addConnection", "source": "Schedule", "target": "Get Data" }, { "type": "addConnection", "source": "Get Data", "target": "Save to Database" } ] } ``` All operations will be processed correctly regardless of order! ``` -------------------------------------------------------------------------------- /src/database/database-adapter.ts: -------------------------------------------------------------------------------- ```typescript import { promises as fs } from 'fs'; import * as fsSync from 'fs'; import path from 'path'; import { logger } from '../utils/logger'; /** * Unified database interface that abstracts better-sqlite3 and sql.js */ export interface DatabaseAdapter { prepare(sql: string): PreparedStatement; exec(sql: string): void; close(): void; pragma(key: string, value?: any): any; readonly inTransaction: boolean; transaction<T>(fn: () => T): T; checkFTS5Support(): boolean; } export interface PreparedStatement { run(...params: any[]): RunResult; get(...params: any[]): any; all(...params: any[]): any[]; iterate(...params: any[]): IterableIterator<any>; pluck(toggle?: boolean): this; expand(toggle?: boolean): this; raw(toggle?: boolean): this; columns(): ColumnDefinition[]; bind(...params: any[]): this; } export interface RunResult { changes: number; lastInsertRowid: number | bigint; } export interface ColumnDefinition { name: string; column: string | null; table: string | null; database: string | null; type: string | null; } /** * Factory function to create a database adapter * Tries better-sqlite3 first, falls back to sql.js if needed */ export async function createDatabaseAdapter(dbPath: string): Promise<DatabaseAdapter> { // Log Node.js version information // Only log in non-stdio mode if (process.env.MCP_MODE !== 'stdio') { logger.info(`Node.js version: ${process.version}`); } // Only log in non-stdio mode if (process.env.MCP_MODE !== 'stdio') { logger.info(`Platform: ${process.platform} ${process.arch}`); } // First, try to use better-sqlite3 try { if (process.env.MCP_MODE !== 'stdio') { logger.info('Attempting to use better-sqlite3...'); } const adapter = await createBetterSQLiteAdapter(dbPath); if (process.env.MCP_MODE !== 'stdio') { logger.info('Successfully initialized better-sqlite3 adapter'); } return adapter; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); // Check if it's a version mismatch error if (errorMessage.includes('NODE_MODULE_VERSION') || errorMessage.includes('was compiled against a different Node.js version')) { if (process.env.MCP_MODE !== 'stdio') { logger.warn(`Node.js version mismatch detected. Better-sqlite3 was compiled for a different Node.js version.`); } if (process.env.MCP_MODE !== 'stdio') { logger.warn(`Current Node.js version: ${process.version}`); } } if (process.env.MCP_MODE !== 'stdio') { logger.warn('Failed to initialize better-sqlite3, falling back to sql.js', error); } // Fall back to sql.js try { const adapter = await createSQLJSAdapter(dbPath); if (process.env.MCP_MODE !== 'stdio') { logger.info('Successfully initialized sql.js adapter (pure JavaScript, no native dependencies)'); } return adapter; } catch (sqlJsError) { if (process.env.MCP_MODE !== 'stdio') { logger.error('Failed to initialize sql.js adapter', sqlJsError); } throw new Error('Failed to initialize any database adapter'); } } } /** * Create better-sqlite3 adapter */ async function createBetterSQLiteAdapter(dbPath: string): Promise<DatabaseAdapter> { try { const Database = require('better-sqlite3'); const db = new Database(dbPath); return new BetterSQLiteAdapter(db); } catch (error) { throw new Error(`Failed to create better-sqlite3 adapter: ${error}`); } } /** * Create sql.js adapter with persistence */ async function createSQLJSAdapter(dbPath: string): Promise<DatabaseAdapter> { let initSqlJs; try { initSqlJs = require('sql.js'); } catch (error) { logger.error('Failed to load sql.js module:', error); throw new Error('sql.js module not found. This might be an issue with npm package installation.'); } // Initialize sql.js const SQL = await initSqlJs({ // This will look for the wasm file in node_modules locateFile: (file: string) => { if (file.endsWith('.wasm')) { // Try multiple paths to find the WASM file const possiblePaths = [ // Local development path path.join(__dirname, '../../node_modules/sql.js/dist/', file), // When installed as npm package path.join(__dirname, '../../../sql.js/dist/', file), // Alternative npm package path path.join(process.cwd(), 'node_modules/sql.js/dist/', file), // Try to resolve from require path.join(path.dirname(require.resolve('sql.js')), '../dist/', file) ]; // Find the first existing path for (const tryPath of possiblePaths) { if (fsSync.existsSync(tryPath)) { if (process.env.MCP_MODE !== 'stdio') { logger.debug(`Found WASM file at: ${tryPath}`); } return tryPath; } } // If not found, try the last resort - require.resolve try { const wasmPath = require.resolve('sql.js/dist/sql-wasm.wasm'); if (process.env.MCP_MODE !== 'stdio') { logger.debug(`Found WASM file via require.resolve: ${wasmPath}`); } return wasmPath; } catch (e) { // Fall back to the default path logger.warn(`Could not find WASM file, using default path: ${file}`); return file; } } return file; } }); // Try to load existing database let db: any; try { const data = await fs.readFile(dbPath); db = new SQL.Database(new Uint8Array(data)); logger.info(`Loaded existing database from ${dbPath}`); } catch (error) { // Create new database if file doesn't exist db = new SQL.Database(); logger.info(`Created new database at ${dbPath}`); } return new SQLJSAdapter(db, dbPath); } /** * Adapter for better-sqlite3 */ class BetterSQLiteAdapter implements DatabaseAdapter { constructor(private db: any) {} prepare(sql: string): PreparedStatement { const stmt = this.db.prepare(sql); return new BetterSQLiteStatement(stmt); } exec(sql: string): void { this.db.exec(sql); } close(): void { this.db.close(); } pragma(key: string, value?: any): any { return this.db.pragma(key, value); } get inTransaction(): boolean { return this.db.inTransaction; } transaction<T>(fn: () => T): T { return this.db.transaction(fn)(); } checkFTS5Support(): boolean { try { // Test if FTS5 is available this.exec("CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);"); this.exec("DROP TABLE IF EXISTS test_fts5;"); return true; } catch (error) { return false; } } } /** * Adapter for sql.js with persistence */ class SQLJSAdapter implements DatabaseAdapter { private saveTimer: NodeJS.Timeout | null = null; private saveIntervalMs: number; private closed = false; // Prevent multiple close() calls // Default save interval: 5 seconds (balance between data safety and performance) // Configurable via SQLJS_SAVE_INTERVAL_MS environment variable // // DATA LOSS WINDOW: Up to 5 seconds of database changes may be lost if process // crashes before scheduleSave() timer fires. This is acceptable because: // 1. close() calls saveToFile() immediately on graceful shutdown // 2. Docker/Kubernetes SIGTERM provides 30s for cleanup (more than enough) // 3. The alternative (100ms interval) caused 2.2GB memory leaks in production // 4. MCP server is primarily read-heavy (writes are rare) private static readonly DEFAULT_SAVE_INTERVAL_MS = 5000; constructor(private db: any, private dbPath: string) { // Read save interval from environment or use default const envInterval = process.env.SQLJS_SAVE_INTERVAL_MS; this.saveIntervalMs = envInterval ? parseInt(envInterval, 10) : SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS; // Validate interval (minimum 100ms, maximum 60000ms = 1 minute) if (isNaN(this.saveIntervalMs) || this.saveIntervalMs < 100 || this.saveIntervalMs > 60000) { logger.warn( `Invalid SQLJS_SAVE_INTERVAL_MS value: ${envInterval} (must be 100-60000ms), ` + `using default ${SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS}ms` ); this.saveIntervalMs = SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS; } logger.debug(`SQLJSAdapter initialized with save interval: ${this.saveIntervalMs}ms`); // NOTE: No initial save scheduled here (optimization) // Database is either: // 1. Loaded from existing file (already persisted), or // 2. New database (will be saved on first write operation) } prepare(sql: string): PreparedStatement { const stmt = this.db.prepare(sql); // Don't schedule save on prepare - only on actual writes (via SQLJSStatement.run()) return new SQLJSStatement(stmt, () => this.scheduleSave()); } exec(sql: string): void { this.db.exec(sql); this.scheduleSave(); } close(): void { if (this.closed) { logger.debug('SQLJSAdapter already closed, skipping'); return; } this.saveToFile(); if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } this.db.close(); this.closed = true; } pragma(key: string, value?: any): any { // sql.js doesn't support pragma in the same way // We'll handle specific pragmas as needed if (key === 'journal_mode' && value === 'WAL') { // WAL mode not supported in sql.js, ignore return 'memory'; } return null; } get inTransaction(): boolean { // sql.js doesn't expose transaction state return false; } transaction<T>(fn: () => T): T { // Simple transaction implementation for sql.js try { this.exec('BEGIN'); const result = fn(); this.exec('COMMIT'); return result; } catch (error) { this.exec('ROLLBACK'); throw error; } } checkFTS5Support(): boolean { try { // Test if FTS5 is available this.exec("CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);"); this.exec("DROP TABLE IF EXISTS test_fts5;"); return true; } catch (error) { // sql.js doesn't support FTS5 return false; } } private scheduleSave(): void { if (this.saveTimer) { clearTimeout(this.saveTimer); } // Save after configured interval of inactivity (default: 5000ms) // This debouncing reduces memory churn from frequent buffer allocations // // NOTE: Under constant write load, saves may be delayed until writes stop. // This is acceptable because: // 1. MCP server is primarily read-heavy (node lookups, searches) // 2. Writes are rare (only during database rebuilds) // 3. close() saves immediately on shutdown, flushing any pending changes this.saveTimer = setTimeout(() => { this.saveToFile(); }, this.saveIntervalMs); } private saveToFile(): void { try { // Export database to Uint8Array (2-5MB typical) const data = this.db.export(); // Write directly without Buffer.from() copy (saves 50% memory allocation) // writeFileSync accepts Uint8Array directly, no need for Buffer conversion fsSync.writeFileSync(this.dbPath, data); logger.debug(`Database saved to ${this.dbPath}`); // Note: 'data' reference is automatically cleared when function exits // V8 GC will reclaim the Uint8Array once it's no longer referenced } catch (error) { logger.error('Failed to save database', error); } } } /** * Statement wrapper for better-sqlite3 */ class BetterSQLiteStatement implements PreparedStatement { constructor(private stmt: any) {} run(...params: any[]): RunResult { return this.stmt.run(...params); } get(...params: any[]): any { return this.stmt.get(...params); } all(...params: any[]): any[] { return this.stmt.all(...params); } iterate(...params: any[]): IterableIterator<any> { return this.stmt.iterate(...params); } pluck(toggle?: boolean): this { this.stmt.pluck(toggle); return this; } expand(toggle?: boolean): this { this.stmt.expand(toggle); return this; } raw(toggle?: boolean): this { this.stmt.raw(toggle); return this; } columns(): ColumnDefinition[] { return this.stmt.columns(); } bind(...params: any[]): this { this.stmt.bind(...params); return this; } } /** * Statement wrapper for sql.js */ class SQLJSStatement implements PreparedStatement { private boundParams: any = null; constructor(private stmt: any, private onModify: () => void) {} run(...params: any[]): RunResult { try { if (params.length > 0) { this.bindParams(params); if (this.boundParams) { this.stmt.bind(this.boundParams); } } this.stmt.run(); this.onModify(); // sql.js doesn't provide changes/lastInsertRowid easily return { changes: 1, // Assume success means 1 change lastInsertRowid: 0 }; } catch (error) { this.stmt.reset(); throw error; } } get(...params: any[]): any { try { if (params.length > 0) { this.bindParams(params); if (this.boundParams) { this.stmt.bind(this.boundParams); } } if (this.stmt.step()) { const result = this.stmt.getAsObject(); this.stmt.reset(); return this.convertIntegerColumns(result); } this.stmt.reset(); return undefined; } catch (error) { this.stmt.reset(); throw error; } } all(...params: any[]): any[] { try { if (params.length > 0) { this.bindParams(params); if (this.boundParams) { this.stmt.bind(this.boundParams); } } const results: any[] = []; while (this.stmt.step()) { results.push(this.convertIntegerColumns(this.stmt.getAsObject())); } this.stmt.reset(); return results; } catch (error) { this.stmt.reset(); throw error; } } iterate(...params: any[]): IterableIterator<any> { // sql.js doesn't support generators well, return array iterator return this.all(...params)[Symbol.iterator](); } pluck(toggle?: boolean): this { // Not directly supported in sql.js return this; } expand(toggle?: boolean): this { // Not directly supported in sql.js return this; } raw(toggle?: boolean): this { // Not directly supported in sql.js return this; } columns(): ColumnDefinition[] { // sql.js has different column info return []; } bind(...params: any[]): this { this.bindParams(params); return this; } private bindParams(params: any[]): void { if (params.length === 0) { this.boundParams = null; return; } if (params.length === 1 && typeof params[0] === 'object' && !Array.isArray(params[0]) && params[0] !== null) { // Named parameters passed as object this.boundParams = params[0]; } else { // Positional parameters - sql.js uses array for positional // Filter out undefined values that might cause issues this.boundParams = params.map(p => p === undefined ? null : p); } } /** * Convert SQLite integer columns to JavaScript numbers * sql.js returns all values as strings, but we need proper types for boolean conversion */ private convertIntegerColumns(row: any): any { if (!row) return row; // Known integer columns in the nodes table const integerColumns = ['is_ai_tool', 'is_trigger', 'is_webhook', 'is_versioned']; const converted = { ...row }; for (const col of integerColumns) { if (col in converted && typeof converted[col] === 'string') { converted[col] = parseInt(converted[col], 10); } } return converted; } } ``` -------------------------------------------------------------------------------- /tests/unit/database/template-repository-core.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { TemplateRepository, StoredTemplate } from '../../../src/templates/template-repository'; import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter'; import { TemplateWorkflow, TemplateDetail } from '../../../src/templates/template-fetcher'; // Mock logger vi.mock('../../../src/utils/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } })); // Mock template sanitizer vi.mock('../../../src/utils/template-sanitizer', () => { class MockTemplateSanitizer { sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false })); detectTokens = vi.fn(() => []); } return { TemplateSanitizer: MockTemplateSanitizer }; }); // Create mock database adapter class MockDatabaseAdapter implements DatabaseAdapter { private statements = new Map<string, MockPreparedStatement>(); private mockData = new Map<string, any>(); private _fts5Support = true; prepare = vi.fn((sql: string) => { if (!this.statements.has(sql)) { this.statements.set(sql, new MockPreparedStatement(sql, this.mockData)); } return this.statements.get(sql)!; }); exec = vi.fn(); close = vi.fn(); pragma = vi.fn(); transaction = vi.fn((fn: () => any) => fn()); checkFTS5Support = vi.fn(() => this._fts5Support); inTransaction = false; // Test helpers _setFTS5Support(supported: boolean) { this._fts5Support = supported; } _setMockData(key: string, value: any) { this.mockData.set(key, value); } _getStatement(sql: string) { return this.statements.get(sql); } } class MockPreparedStatement implements PreparedStatement { run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 })); get = vi.fn(); all = vi.fn(() => []); iterate = vi.fn(); pluck = vi.fn(() => this); expand = vi.fn(() => this); raw = vi.fn(() => this); columns = vi.fn(() => []); bind = vi.fn(() => this); constructor(private sql: string, private mockData: Map<string, any>) { // Configure based on SQL patterns if (sql.includes('SELECT * FROM templates WHERE id = ?')) { this.get = vi.fn((id: number) => this.mockData.get(`template:${id}`)); } if (sql.includes('SELECT * FROM templates') && sql.includes('LIMIT')) { this.all = vi.fn(() => this.mockData.get('all_templates') || []); } if (sql.includes('templates_fts')) { this.all = vi.fn(() => this.mockData.get('fts_results') || []); } if (sql.includes('WHERE name LIKE')) { this.all = vi.fn(() => this.mockData.get('like_results') || []); } if (sql.includes('COUNT(*) as count')) { this.get = vi.fn(() => ({ count: this.mockData.get('template_count') || 0 })); } if (sql.includes('AVG(views)')) { this.get = vi.fn(() => ({ avg: this.mockData.get('avg_views') || 0 })); } if (sql.includes('sqlite_master')) { this.get = vi.fn(() => this.mockData.get('fts_table_exists') ? { name: 'templates_fts' } : undefined); } } } describe('TemplateRepository - Core Functionality', () => { let repository: TemplateRepository; let mockAdapter: MockDatabaseAdapter; beforeEach(() => { vi.clearAllMocks(); mockAdapter = new MockDatabaseAdapter(); mockAdapter._setMockData('fts_table_exists', false); // Default to creating FTS repository = new TemplateRepository(mockAdapter); }); describe('FTS5 initialization', () => { it('should initialize FTS5 when supported', () => { expect(mockAdapter.checkFTS5Support).toHaveBeenCalled(); expect(mockAdapter.exec).toHaveBeenCalledWith(expect.stringContaining('CREATE VIRTUAL TABLE')); }); it('should skip FTS5 when not supported', () => { mockAdapter._setFTS5Support(false); mockAdapter.exec.mockClear(); const newRepo = new TemplateRepository(mockAdapter); expect(mockAdapter.exec).not.toHaveBeenCalledWith(expect.stringContaining('CREATE VIRTUAL TABLE')); }); }); describe('saveTemplate', () => { it('should save a template with proper JSON serialization', () => { const workflow: TemplateWorkflow = { id: 123, name: 'Test Workflow', description: 'A test workflow', user: { id: 1, name: 'John Doe', username: 'johndoe', verified: true }, nodes: [ { id: 1, name: 'n8n-nodes-base.httpRequest', icon: 'fa:globe' }, { id: 2, name: 'n8n-nodes-base.slack', icon: 'fa:slack' } ], totalViews: 1000, createdAt: '2024-01-01T00:00:00Z' }; const detail: TemplateDetail = { id: 123, name: 'Test Workflow', description: 'A test workflow', views: 1000, createdAt: '2024-01-01T00:00:00Z', workflow: { nodes: [ { type: 'n8n-nodes-base.httpRequest', name: 'HTTP Request', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }, { type: 'n8n-nodes-base.slack', name: 'Slack', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 } ], connections: {}, settings: {} } }; const categories = ['automation', 'integration']; repository.saveTemplate(workflow, detail, categories); const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.calls.find( call => call[0].includes('INSERT OR REPLACE INTO templates') )?.[0] || ''); // The implementation now uses gzip compression, so we just verify the call happened expect(stmt?.run).toHaveBeenCalledWith( 123, // id 123, // workflow_id 'Test Workflow', 'A test workflow', 'John Doe', 'johndoe', 1, // verified JSON.stringify(['n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack']), expect.any(String), // compressed workflow JSON JSON.stringify(['automation', 'integration']), 1000, // views '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z', 'https://n8n.io/workflows/123' ); }); }); describe('getTemplate', () => { it('should retrieve a specific template by ID', () => { const mockTemplate: StoredTemplate = { id: 123, workflow_id: 123, name: 'Test Template', description: 'Description', author_name: 'Author', author_username: 'author', author_verified: 1, nodes_used: '[]', workflow_json: '{}', categories: '[]', views: 500, created_at: '2024-01-01', updated_at: '2024-01-01', url: 'https://n8n.io/workflows/123', scraped_at: '2024-01-01' }; mockAdapter._setMockData('template:123', mockTemplate); const result = repository.getTemplate(123); expect(result).toEqual(mockTemplate); }); it('should return null for non-existent template', () => { const result = repository.getTemplate(999); expect(result).toBeNull(); }); }); describe('searchTemplates', () => { it('should use FTS5 search when available', () => { const ftsResults: StoredTemplate[] = [{ id: 1, workflow_id: 1, name: 'Chatbot Workflow', description: 'AI chatbot', author_name: 'Author', author_username: 'author', author_verified: 0, nodes_used: '[]', workflow_json: '{}', categories: '[]', views: 100, created_at: '2024-01-01', updated_at: '2024-01-01', url: 'https://n8n.io/workflows/1', scraped_at: '2024-01-01' }]; mockAdapter._setMockData('fts_results', ftsResults); const results = repository.searchTemplates('chatbot', 10); expect(results).toEqual(ftsResults); }); it('should fall back to LIKE search when FTS5 is not supported', () => { mockAdapter._setFTS5Support(false); const newRepo = new TemplateRepository(mockAdapter); const likeResults: StoredTemplate[] = [{ id: 3, workflow_id: 3, name: 'LIKE only', description: 'No FTS5', author_name: 'Author', author_username: 'author', author_verified: 0, nodes_used: '[]', workflow_json: '{}', categories: '[]', views: 25, created_at: '2024-01-01', updated_at: '2024-01-01', url: 'https://n8n.io/workflows/3', scraped_at: '2024-01-01' }]; mockAdapter._setMockData('like_results', likeResults); const results = newRepo.searchTemplates('test', 20); expect(results).toEqual(likeResults); }); }); describe('getTemplatesByNodes', () => { it('should find templates using specific node types', () => { const mockTemplates: StoredTemplate[] = [{ id: 1, workflow_id: 1, name: 'HTTP Workflow', description: 'Uses HTTP', author_name: 'Author', author_username: 'author', author_verified: 1, nodes_used: '["n8n-nodes-base.httpRequest"]', workflow_json: '{}', categories: '[]', views: 100, created_at: '2024-01-01', updated_at: '2024-01-01', url: 'https://n8n.io/workflows/1', scraped_at: '2024-01-01' }]; // Set up the mock to return our templates const stmt = new MockPreparedStatement('', new Map()); stmt.all = vi.fn(() => mockTemplates); mockAdapter.prepare = vi.fn(() => stmt); const results = repository.getTemplatesByNodes(['n8n-nodes-base.httpRequest'], 5); expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.httpRequest"%', 5, 0); expect(results).toEqual(mockTemplates); }); }); describe('getTemplatesForTask', () => { it('should return templates for known tasks', () => { const aiTemplates: StoredTemplate[] = [{ id: 1, workflow_id: 1, name: 'AI Workflow', description: 'Uses OpenAI', author_name: 'Author', author_username: 'author', author_verified: 1, nodes_used: '["@n8n/n8n-nodes-langchain.openAi"]', workflow_json: '{}', categories: '["ai"]', views: 1000, created_at: '2024-01-01', updated_at: '2024-01-01', url: 'https://n8n.io/workflows/1', scraped_at: '2024-01-01' }]; const stmt = new MockPreparedStatement('', new Map()); stmt.all = vi.fn(() => aiTemplates); mockAdapter.prepare = vi.fn(() => stmt); const results = repository.getTemplatesForTask('ai_automation'); expect(results).toEqual(aiTemplates); }); it('should return empty array for unknown task', () => { const results = repository.getTemplatesForTask('unknown_task'); expect(results).toEqual([]); }); }); describe('template statistics', () => { it('should get template count', () => { mockAdapter._setMockData('template_count', 42); const count = repository.getTemplateCount(); expect(count).toBe(42); }); it('should get template statistics', () => { mockAdapter._setMockData('template_count', 100); mockAdapter._setMockData('avg_views', 250.5); const topTemplates = [ { nodes_used: '["n8n-nodes-base.httpRequest", "n8n-nodes-base.slack"]' }, { nodes_used: '["n8n-nodes-base.httpRequest", "n8n-nodes-base.code"]' }, { nodes_used: '["n8n-nodes-base.slack"]' } ]; const stmt = new MockPreparedStatement('', new Map()); stmt.all = vi.fn(() => topTemplates); mockAdapter.prepare = vi.fn((sql) => { if (sql.includes('ORDER BY views DESC')) { return stmt; } return new MockPreparedStatement(sql, mockAdapter['mockData']); }); const stats = repository.getTemplateStats(); expect(stats.totalTemplates).toBe(100); expect(stats.averageViews).toBe(251); expect(stats.topUsedNodes).toContainEqual({ node: 'n8n-nodes-base.httpRequest', count: 2 }); }); }); describe('pagination count methods', () => { it('should get node templates count', () => { mockAdapter._setMockData('node_templates_count', 15); const stmt = new MockPreparedStatement('', new Map()); stmt.get = vi.fn(() => ({ count: 15 })); mockAdapter.prepare = vi.fn(() => stmt); const count = repository.getNodeTemplatesCount(['n8n-nodes-base.webhook']); expect(count).toBe(15); expect(stmt.get).toHaveBeenCalledWith('%"n8n-nodes-base.webhook"%'); }); it('should get search count', () => { const stmt = new MockPreparedStatement('', new Map()); stmt.get = vi.fn(() => ({ count: 8 })); mockAdapter.prepare = vi.fn(() => stmt); const count = repository.getSearchCount('webhook'); expect(count).toBe(8); }); it('should get task templates count', () => { const stmt = new MockPreparedStatement('', new Map()); stmt.get = vi.fn(() => ({ count: 12 })); mockAdapter.prepare = vi.fn(() => stmt); const count = repository.getTaskTemplatesCount('ai_automation'); expect(count).toBe(12); }); it('should handle pagination in getAllTemplates', () => { const mockTemplates = [ { id: 1, name: 'Template 1' }, { id: 2, name: 'Template 2' } ]; const stmt = new MockPreparedStatement('', new Map()); stmt.all = vi.fn(() => mockTemplates); mockAdapter.prepare = vi.fn(() => stmt); const results = repository.getAllTemplates(10, 5, 'name'); expect(results).toEqual(mockTemplates); expect(stmt.all).toHaveBeenCalledWith(10, 5); }); it('should handle pagination in getTemplatesByNodes', () => { const mockTemplates = [ { id: 1, nodes_used: '["n8n-nodes-base.webhook"]' } ]; const stmt = new MockPreparedStatement('', new Map()); stmt.all = vi.fn(() => mockTemplates); mockAdapter.prepare = vi.fn(() => stmt); const results = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 5, 10); expect(results).toEqual(mockTemplates); expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.webhook"%', 5, 10); }); it('should handle pagination in searchTemplates', () => { const mockTemplates = [ { id: 1, name: 'Search Result 1' } ]; mockAdapter._setMockData('fts_results', mockTemplates); const stmt = new MockPreparedStatement('', new Map()); stmt.all = vi.fn(() => mockTemplates); mockAdapter.prepare = vi.fn(() => stmt); const results = repository.searchTemplates('webhook', 20, 40); expect(results).toEqual(mockTemplates); }); it('should handle pagination in getTemplatesForTask', () => { const mockTemplates = [ { id: 1, categories: '["ai"]' } ]; const stmt = new MockPreparedStatement('', new Map()); stmt.all = vi.fn(() => mockTemplates); mockAdapter.prepare = vi.fn(() => stmt); const results = repository.getTemplatesForTask('ai_automation', 15, 30); expect(results).toEqual(mockTemplates); }); }); describe('maintenance operations', () => { it('should clear all templates', () => { repository.clearTemplates(); expect(mockAdapter.exec).toHaveBeenCalledWith('DELETE FROM templates'); }); it('should rebuild FTS5 index when supported', () => { repository.rebuildTemplateFTS(); expect(mockAdapter.exec).toHaveBeenCalledWith('DELETE FROM templates_fts'); expect(mockAdapter.exec).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO templates_fts') ); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/flexible-instance-security-advanced.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Advanced security and error handling tests for flexible instance configuration * * This test file focuses on advanced security scenarios, error handling edge cases, * and comprehensive testing of security-related code paths */ import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; import { InstanceContext, validateInstanceContext } from '../../src/types/instance-context'; import { getN8nApiClient } from '../../src/mcp/handlers-n8n-manager'; import { getN8nApiConfigFromContext } from '../../src/config/n8n-api'; import { N8nApiClient } from '../../src/services/n8n-api-client'; import { logger } from '../../src/utils/logger'; import { createHash } from 'crypto'; // Mock dependencies vi.mock('../../src/services/n8n-api-client'); vi.mock('../../src/config/n8n-api'); vi.mock('../../src/utils/logger'); describe('Advanced Security and Error Handling Tests', () => { let mockN8nApiClient: Mock; let mockGetN8nApiConfigFromContext: Mock; let mockLogger: any; // Logger mock has complex type beforeEach(() => { vi.resetAllMocks(); vi.resetModules(); mockN8nApiClient = vi.mocked(N8nApiClient); mockGetN8nApiConfigFromContext = vi.mocked(getN8nApiConfigFromContext); mockLogger = vi.mocked(logger); }); afterEach(() => { vi.clearAllMocks(); }); describe('Advanced Input Sanitization', () => { it('should handle SQL injection attempts in context fields', () => { const maliciousContext = { n8nApiUrl: "https://api.n8n.cloud'; DROP TABLE users; --", n8nApiKey: "key'; DELETE FROM secrets; --", instanceId: "'; SELECT * FROM passwords; --" }; const validation = validateInstanceContext(maliciousContext); // URL should be invalid due to special characters expect(validation.valid).toBe(false); expect(validation.errors?.some(error => error.startsWith('Invalid n8nApiUrl:'))).toBe(true); }); it('should handle XSS attempts in context fields', () => { const xssContext = { n8nApiUrl: 'https://api.n8n.cloud<script>alert("xss")</script>', n8nApiKey: '<img src=x onerror=alert("xss")>', instanceId: 'javascript:alert("xss")' }; const validation = validateInstanceContext(xssContext); // Should be invalid due to malformed URL expect(validation.valid).toBe(false); }); it('should handle extremely long input values', () => { const longString = 'a'.repeat(100000); const longContext: InstanceContext = { n8nApiUrl: `https://api.n8n.cloud/${longString}`, n8nApiKey: longString, instanceId: longString }; // Should handle without crashing expect(() => validateInstanceContext(longContext)).not.toThrow(); expect(() => getN8nApiClient(longContext)).not.toThrow(); }); it('should handle Unicode and special characters safely', () => { const unicodeContext: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud/测试', n8nApiKey: 'key-ñáéíóú-кириллица-🚀', instanceId: '用户-123-αβγ' }; expect(() => validateInstanceContext(unicodeContext)).not.toThrow(); expect(() => getN8nApiClient(unicodeContext)).not.toThrow(); }); it('should handle null bytes and control characters', () => { const maliciousContext = { n8nApiUrl: 'https://api.n8n.cloud\0\x01\x02', n8nApiKey: 'key\r\n\t\0', instanceId: 'instance\x00\x1f' }; expect(() => validateInstanceContext(maliciousContext)).not.toThrow(); }); }); describe('Prototype Pollution Protection', () => { it('should not be vulnerable to prototype pollution via __proto__', () => { const pollutionAttempt = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key', __proto__: { isAdmin: true, polluted: 'value' } }; expect(() => validateInstanceContext(pollutionAttempt)).not.toThrow(); // Verify prototype wasn't polluted const cleanObject = {}; expect((cleanObject as any).isAdmin).toBeUndefined(); expect((cleanObject as any).polluted).toBeUndefined(); }); it('should not be vulnerable to prototype pollution via constructor', () => { const pollutionAttempt = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key', constructor: { prototype: { isAdmin: true } } }; expect(() => validateInstanceContext(pollutionAttempt)).not.toThrow(); }); it('should handle Object.create(null) safely', () => { const nullProtoObject = Object.create(null); nullProtoObject.n8nApiUrl = 'https://api.n8n.cloud'; nullProtoObject.n8nApiKey = 'test-key'; expect(() => validateInstanceContext(nullProtoObject)).not.toThrow(); }); }); describe('Memory Exhaustion Protection', () => { it('should handle deeply nested objects without stack overflow', () => { let deepObject: any = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'key' }; for (let i = 0; i < 1000; i++) { deepObject = { nested: deepObject }; } deepObject.metadata = deepObject; expect(() => validateInstanceContext(deepObject)).not.toThrow(); }); it('should handle circular references in metadata', () => { const circularContext: any = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key', metadata: {} }; circularContext.metadata.self = circularContext; circularContext.metadata.circular = circularContext.metadata; expect(() => validateInstanceContext(circularContext)).not.toThrow(); }); it('should handle massive arrays in metadata', () => { const massiveArray = new Array(100000).fill('data'); const arrayContext: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key', metadata: { massiveArray } }; expect(() => validateInstanceContext(arrayContext)).not.toThrow(); }); }); describe('Cache Security and Isolation', () => { it('should prevent cache key collisions through hash security', () => { mockGetN8nApiConfigFromContext.mockReturnValue({ baseUrl: 'https://api.n8n.cloud', apiKey: 'test-key', timeout: 30000, maxRetries: 3 }); // Create contexts that might produce hash collisions const context1: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'abc', instanceId: 'def' }; const context2: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'ab', instanceId: 'cdef' }; const hash1 = createHash('sha256') .update(`${context1.n8nApiUrl}:${context1.n8nApiKey}:${context1.instanceId}`) .digest('hex'); const hash2 = createHash('sha256') .update(`${context2.n8nApiUrl}:${context2.n8nApiKey}:${context2.instanceId}`) .digest('hex'); expect(hash1).not.toBe(hash2); // Verify separate cache entries getN8nApiClient(context1); getN8nApiClient(context2); expect(mockN8nApiClient).toHaveBeenCalledTimes(2); }); it('should not expose sensitive data in cache key logs', () => { const loggerInfoSpy = vi.spyOn(logger, 'info'); const sensitiveContext: InstanceContext = { n8nApiUrl: 'https://super-secret-api.example.com/v1/secret', n8nApiKey: 'sk_live_SUPER_SECRET_API_KEY_123456789', instanceId: 'production-instance-sensitive' }; mockGetN8nApiConfigFromContext.mockReturnValue({ baseUrl: 'https://super-secret-api.example.com/v1/secret', apiKey: 'sk_live_SUPER_SECRET_API_KEY_123456789', timeout: 30000, maxRetries: 3 }); getN8nApiClient(sensitiveContext); // Check all log calls const allLogData = loggerInfoSpy.mock.calls.flat().join(' '); // Should not contain sensitive data expect(allLogData).not.toContain('sk_live_SUPER_SECRET_API_KEY_123456789'); expect(allLogData).not.toContain('super-secret-api-key'); expect(allLogData).not.toContain('/v1/secret'); // Logs should not expose the actual API key value expect(allLogData).not.toContain('SUPER_SECRET'); }); it('should handle hash collisions securely', () => { // Mock a scenario where two different inputs could theoretically // produce the same hash (extremely unlikely with SHA-256) const context1: InstanceContext = { n8nApiUrl: 'https://api1.n8n.cloud', n8nApiKey: 'key1', instanceId: 'instance1' }; const context2: InstanceContext = { n8nApiUrl: 'https://api2.n8n.cloud', n8nApiKey: 'key2', instanceId: 'instance2' }; mockGetN8nApiConfigFromContext.mockReturnValue({ baseUrl: 'https://api.n8n.cloud', apiKey: 'test-key', timeout: 30000, maxRetries: 3 }); // Even if hashes were identical, different configs would be isolated getN8nApiClient(context1); getN8nApiClient(context2); expect(mockN8nApiClient).toHaveBeenCalledTimes(2); }); }); describe('Error Message Security', () => { it('should not expose sensitive data in validation error messages', () => { const sensitiveContext: InstanceContext = { n8nApiUrl: 'https://secret-api.example.com/private-endpoint', n8nApiKey: 'super-secret-key-123', n8nApiTimeout: -1 }; const validation = validateInstanceContext(sensitiveContext); expect(validation.valid).toBe(false); // Error messages should not contain sensitive data const errorMessage = validation.errors?.join(' ') || ''; expect(errorMessage).not.toContain('super-secret-key-123'); expect(errorMessage).not.toContain('secret-api'); expect(errorMessage).not.toContain('private-endpoint'); }); it('should sanitize error details in API responses', () => { const sensitiveContext: InstanceContext = { n8nApiUrl: 'invalid-url-with-secrets/api/key=secret123', n8nApiKey: 'another-secret-key' }; const validation = validateInstanceContext(sensitiveContext); expect(validation.valid).toBe(false); expect(validation.errors?.some(error => error.startsWith('Invalid n8nApiUrl:'))).toBe(true); // Should not contain the actual invalid URL const errorData = JSON.stringify(validation); expect(errorData).not.toContain('secret123'); expect(errorData).not.toContain('another-secret-key'); }); }); describe('Resource Exhaustion Protection', () => { it('should handle memory pressure gracefully', () => { // Create many large contexts to simulate memory pressure const largeData = 'x'.repeat(10000); for (let i = 0; i < 100; i++) { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: `key-${i}`, instanceId: `instance-${i}`, metadata: { largeData: largeData, moreData: new Array(1000).fill(largeData) } }; expect(() => validateInstanceContext(context)).not.toThrow(); } }); it('should handle high frequency validation requests', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'frequency-test-key' }; // Rapid fire validation for (let i = 0; i < 1000; i++) { expect(() => validateInstanceContext(context)).not.toThrow(); } }); }); describe('Cryptographic Security', () => { it('should use cryptographically secure hash function', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'crypto-test-key', instanceId: 'crypto-instance' }; // Generate hash multiple times - should be deterministic const hash1 = createHash('sha256') .update(`${context.n8nApiUrl}:${context.n8nApiKey}:${context.instanceId}`) .digest('hex'); const hash2 = createHash('sha256') .update(`${context.n8nApiUrl}:${context.n8nApiKey}:${context.instanceId}`) .digest('hex'); expect(hash1).toBe(hash2); expect(hash1).toHaveLength(64); // SHA-256 produces 64-character hex string expect(hash1).toMatch(/^[a-f0-9]{64}$/); }); it('should handle edge cases in hash input', () => { const edgeCases = [ { url: '', key: '', id: '' }, { url: 'https://api.n8n.cloud', key: '', id: '' }, { url: '', key: 'key', id: '' }, { url: '', key: '', id: 'id' }, { url: undefined, key: undefined, id: undefined } ]; edgeCases.forEach((testCase, index) => { expect(() => { createHash('sha256') .update(`${testCase.url || ''}:${testCase.key || ''}:${testCase.id || ''}`) .digest('hex'); }).not.toThrow(); }); }); }); describe('Injection Attack Prevention', () => { it('should prevent command injection through context fields', () => { const commandInjectionContext = { n8nApiUrl: 'https://api.n8n.cloud; rm -rf /', n8nApiKey: '$(whoami)', instanceId: '`cat /etc/passwd`' }; expect(() => validateInstanceContext(commandInjectionContext)).not.toThrow(); // URL should be invalid const validation = validateInstanceContext(commandInjectionContext); expect(validation.valid).toBe(false); }); it('should prevent path traversal attempts', () => { const pathTraversalContext = { n8nApiUrl: 'https://api.n8n.cloud/../../../etc/passwd', n8nApiKey: '..\\..\\windows\\system32\\config\\sam', instanceId: '../secrets.txt' }; expect(() => validateInstanceContext(pathTraversalContext)).not.toThrow(); }); it('should prevent LDAP injection attempts', () => { const ldapInjectionContext = { n8nApiUrl: 'https://api.n8n.cloud)(|(password=*))', n8nApiKey: '*)(uid=*', instanceId: '*))(|(cn=*' }; expect(() => validateInstanceContext(ldapInjectionContext)).not.toThrow(); }); }); describe('State Management Security', () => { it('should maintain isolation between contexts', () => { const context1: InstanceContext = { n8nApiUrl: 'https://tenant1.n8n.cloud', n8nApiKey: 'tenant1-key', instanceId: 'tenant1' }; const context2: InstanceContext = { n8nApiUrl: 'https://tenant2.n8n.cloud', n8nApiKey: 'tenant2-key', instanceId: 'tenant2' }; mockGetN8nApiConfigFromContext .mockReturnValueOnce({ baseUrl: 'https://tenant1.n8n.cloud', apiKey: 'tenant1-key', timeout: 30000, maxRetries: 3 }) .mockReturnValueOnce({ baseUrl: 'https://tenant2.n8n.cloud', apiKey: 'tenant2-key', timeout: 30000, maxRetries: 3 }); const client1 = getN8nApiClient(context1); const client2 = getN8nApiClient(context2); // Should create separate clients expect(mockN8nApiClient).toHaveBeenCalledTimes(2); expect(client1).not.toBe(client2); }); it('should handle concurrent access securely', async () => { const contexts = Array(50).fill(null).map((_, i) => ({ n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: `concurrent-key-${i}`, instanceId: `concurrent-${i}` })); mockGetN8nApiConfigFromContext.mockReturnValue({ baseUrl: 'https://api.n8n.cloud', apiKey: 'test-key', timeout: 30000, maxRetries: 3 }); // Simulate concurrent access const promises = contexts.map(context => Promise.resolve(getN8nApiClient(context)) ); const results = await Promise.all(promises); // All should succeed results.forEach(result => { expect(result).toBeDefined(); }); expect(mockN8nApiClient).toHaveBeenCalledTimes(50); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-expression-format.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { WorkflowValidator } from '../../../src/services/workflow-validator'; import { NodeRepository } from '../../../src/database/node-repository'; import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; // Mock the database vi.mock('../../../src/database/node-repository'); describe('WorkflowValidator - Expression Format Validation', () => { let validator: WorkflowValidator; let mockNodeRepository: any; beforeEach(() => { // Create mock repository mockNodeRepository = { findNodeByType: vi.fn().mockImplementation((type: string) => { // Return mock nodes for common types if (type === 'n8n-nodes-base.emailSend') { return { node_type: 'n8n-nodes-base.emailSend', display_name: 'Email Send', properties: {}, version: 2.1 }; } if (type === 'n8n-nodes-base.github') { return { node_type: 'n8n-nodes-base.github', display_name: 'GitHub', properties: {}, version: 1.1 }; } if (type === 'n8n-nodes-base.webhook') { return { node_type: 'n8n-nodes-base.webhook', display_name: 'Webhook', properties: {}, version: 1 }; } if (type === 'n8n-nodes-base.httpRequest') { return { node_type: 'n8n-nodes-base.httpRequest', display_name: 'HTTP Request', properties: {}, version: 4 }; } return null; }), searchNodes: vi.fn().mockReturnValue([]), getAllNodes: vi.fn().mockReturnValue([]), close: vi.fn() }; validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); }); describe('Expression Format Detection', () => { it('should detect missing = prefix in simple expressions', async () => { const workflow = { nodes: [ { id: '1', name: 'Send Email', type: 'n8n-nodes-base.emailSend', position: [0, 0] as [number, number], parameters: { fromEmail: '{{ $env.SENDER_EMAIL }}', toEmail: '[email protected]', subject: 'Test Email' }, typeVersion: 2.1 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); expect(result.valid).toBe(false); // Find expression format errors const formatErrors = result.errors.filter(e => e.message.includes('Expression format error')); expect(formatErrors).toHaveLength(1); const error = formatErrors[0]; expect(error.message).toContain('Expression format error'); expect(error.message).toContain('fromEmail'); expect(error.message).toContain('{{ $env.SENDER_EMAIL }}'); expect(error.message).toContain('={{ $env.SENDER_EMAIL }}'); }); it('should detect missing resource locator format for GitHub fields', async () => { const workflow = { nodes: [ { id: '1', name: 'GitHub', type: 'n8n-nodes-base.github', position: [0, 0] as [number, number], parameters: { operation: 'createComment', owner: '{{ $vars.GITHUB_OWNER }}', repository: '{{ $vars.GITHUB_REPO }}', issueNumber: 123, body: 'Test comment' }, typeVersion: 1.1 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); expect(result.valid).toBe(false); // Should have errors for both owner and repository const ownerError = result.errors.find(e => e.message.includes('owner')); const repoError = result.errors.find(e => e.message.includes('repository')); expect(ownerError).toBeTruthy(); expect(repoError).toBeTruthy(); expect(ownerError?.message).toContain('resource locator format'); expect(ownerError?.message).toContain('__rl'); }); it('should detect mixed content without prefix', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [0, 0] as [number, number], parameters: { url: 'https://api.example.com/{{ $json.endpoint }}', headers: { Authorization: 'Bearer {{ $env.API_TOKEN }}' } }, typeVersion: 4 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); expect(result.valid).toBe(false); const errors = result.errors.filter(e => e.message.includes('Expression format')); expect(errors.length).toBeGreaterThan(0); // Check for URL error const urlError = errors.find(e => e.message.includes('url')); expect(urlError).toBeTruthy(); expect(urlError?.message).toContain('=https://api.example.com/{{ $json.endpoint }}'); }); it('should accept properly formatted expressions', async () => { const workflow = { nodes: [ { id: '1', name: 'Send Email', type: 'n8n-nodes-base.emailSend', position: [0, 0] as [number, number], parameters: { fromEmail: '={{ $env.SENDER_EMAIL }}', toEmail: '[email protected]', subject: '=Test {{ $json.type }}' }, typeVersion: 2.1 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); // Should have no expression format errors const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); expect(formatErrors).toHaveLength(0); }); it('should accept resource locator format', async () => { const workflow = { nodes: [ { id: '1', name: 'GitHub', type: 'n8n-nodes-base.github', position: [0, 0] as [number, number], parameters: { operation: 'createComment', owner: { __rl: true, value: '={{ $vars.GITHUB_OWNER }}', mode: 'expression' }, repository: { __rl: true, value: '={{ $vars.GITHUB_REPO }}', mode: 'expression' }, issueNumber: 123, body: '=Test comment from {{ $json.author }}' }, typeVersion: 1.1 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); // Should have no expression format errors const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); expect(formatErrors).toHaveLength(0); }); it('should validate nested expressions in complex parameters', async () => { const workflow = { nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [0, 0] as [number, number], parameters: { method: 'POST', url: 'https://api.example.com', sendBody: true, bodyParameters: { parameters: [ { name: 'userId', value: '{{ $json.id }}' }, { name: 'timestamp', value: '={{ $now }}' } ] } }, typeVersion: 4 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); // Should detect the missing prefix in nested parameter const errors = result.errors.filter(e => e.message.includes('Expression format')); expect(errors.length).toBeGreaterThan(0); const nestedError = errors.find(e => e.message.includes('bodyParameters')); expect(nestedError).toBeTruthy(); }); it('should warn about RL format even with prefix', async () => { const workflow = { nodes: [ { id: '1', name: 'GitHub', type: 'n8n-nodes-base.github', position: [0, 0] as [number, number], parameters: { operation: 'createComment', owner: '={{ $vars.GITHUB_OWNER }}', repository: '={{ $vars.GITHUB_REPO }}', issueNumber: 123, body: 'Test' }, typeVersion: 1.1 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); // Should have warnings about using RL format const warnings = result.warnings.filter(w => w.message.includes('resource locator format')); expect(warnings.length).toBeGreaterThan(0); }); }); describe('Real-world workflow examples', () => { it('should validate Email workflow with expression issues', async () => { const workflow = { name: 'Error Notification Workflow', nodes: [ { id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [250, 300] as [number, number], parameters: { path: 'error-handler', httpMethod: 'POST' }, typeVersion: 1 }, { id: 'email-1', name: 'Error Handler', type: 'n8n-nodes-base.emailSend', position: [450, 300] as [number, number], parameters: { fromEmail: '{{ $env.ADMIN_EMAIL }}', toEmail: '[email protected]', subject: 'Error in {{ $json.workflow }}', message: 'An error occurred: {{ $json.error }}', options: { replyTo: '={{ $env.SUPPORT_EMAIL }}' } }, typeVersion: 2.1 } ], connections: { 'Webhook': { main: [[{ node: 'Error Handler', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow); // Should have multiple expression format errors const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); expect(formatErrors.length).toBeGreaterThanOrEqual(3); // fromEmail, subject, message // Check specific errors const fromEmailError = formatErrors.find(e => e.message.includes('fromEmail')); expect(fromEmailError).toBeTruthy(); expect(fromEmailError?.message).toContain('={{ $env.ADMIN_EMAIL }}'); }); it('should validate GitHub workflow with resource locator issues', async () => { const workflow = { name: 'GitHub Issue Handler', nodes: [ { id: 'webhook-1', name: 'Issue Webhook', type: 'n8n-nodes-base.webhook', position: [250, 300] as [number, number], parameters: { path: 'github-issue', httpMethod: 'POST' }, typeVersion: 1 }, { id: 'github-1', name: 'Create Comment', type: 'n8n-nodes-base.github', position: [450, 300] as [number, number], parameters: { operation: 'createComment', owner: '{{ $vars.GITHUB_OWNER }}', repository: '{{ $vars.GITHUB_REPO }}', issueNumber: '={{ $json.body.issue.number }}', body: 'Thanks for the issue @{{ $json.body.issue.user.login }}!' }, typeVersion: 1.1 } ], connections: { 'Issue Webhook': { main: [[{ node: 'Create Comment', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow); // Should have errors for owner, repository, and body const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); expect(formatErrors.length).toBeGreaterThanOrEqual(3); // Check for resource locator suggestions const ownerError = formatErrors.find(e => e.message.includes('owner')); expect(ownerError?.message).toContain('__rl'); expect(ownerError?.message).toContain('resource locator format'); }); it('should provide clear fix examples in error messages', async () => { const workflow = { nodes: [ { id: '1', name: 'Process Data', type: 'n8n-nodes-base.httpRequest', position: [0, 0] as [number, number], parameters: { url: 'https://api.example.com/users/{{ $json.userId }}' }, typeVersion: 4 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); const error = result.errors.find(e => e.message.includes('Expression format')); expect(error).toBeTruthy(); // Error message should contain both incorrect and correct examples expect(error?.message).toContain('Current (incorrect):'); expect(error?.message).toContain('"url": "https://api.example.com/users/{{ $json.userId }}"'); expect(error?.message).toContain('Fixed (correct):'); expect(error?.message).toContain('"url": "=https://api.example.com/users/{{ $json.userId }}"'); }); }); describe('Integration with other validations', () => { it('should validate expression format alongside syntax', async () => { const workflow = { nodes: [ { id: '1', name: 'Test Node', type: 'n8n-nodes-base.httpRequest', position: [0, 0] as [number, number], parameters: { url: '{{ $json.url', // Syntax error: unclosed expression headers: { 'X-Token': '{{ $env.TOKEN }}' // Format error: missing prefix } }, typeVersion: 4 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); // Should have both syntax and format errors const syntaxErrors = result.errors.filter(e => e.message.includes('Unmatched expression brackets')); const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); expect(syntaxErrors.length).toBeGreaterThan(0); expect(formatErrors.length).toBeGreaterThan(0); }); it('should not interfere with node validation', async () => { // Test that expression format validation works alongside other validations const workflow = { nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [0, 0] as [number, number], parameters: { url: '{{ $json.endpoint }}', // Expression format error headers: { Authorization: '={{ $env.TOKEN }}' // Correct format } }, typeVersion: 4 } ], connections: {} }; const result = await validator.validateWorkflow(workflow); // Should have expression format error for url field const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); expect(formatErrors).toHaveLength(1); expect(formatErrors[0].message).toContain('url'); // The workflow should still have structure validation (no trigger warning, etc) // This proves that expression validation doesn't interfere with other checks expect(result.warnings.some(w => w.message.includes('trigger'))).toBe(true); }); }); }); ``` -------------------------------------------------------------------------------- /docs/LIBRARY_USAGE.md: -------------------------------------------------------------------------------- ```markdown # Library Usage Guide - Multi-Tenant / Hosted Deployments This guide covers using n8n-mcp as a library dependency for building multi-tenant hosted services. ## Overview n8n-mcp can be used as a Node.js library to build multi-tenant backends that provide MCP services to multiple users or instances. The package exports all necessary components for integration into your existing services. ## Installation ```bash npm install n8n-mcp ``` ## Core Concepts ### Library Mode vs CLI Mode - **CLI Mode** (default): Single-player usage via `npx n8n-mcp` or Docker - **Library Mode**: Multi-tenant usage by importing and using the `N8NMCPEngine` class ### Instance Context The `InstanceContext` type allows you to pass per-request configuration to the MCP engine: ```typescript interface InstanceContext { // Instance-specific n8n API configuration n8nApiUrl?: string; n8nApiKey?: string; n8nApiTimeout?: number; n8nApiMaxRetries?: number; // Instance identification instanceId?: string; sessionId?: string; // Extensible metadata metadata?: Record<string, any>; } ``` ## Basic Example ```typescript import express from 'express'; import { N8NMCPEngine } from 'n8n-mcp'; const app = express(); const mcpEngine = new N8NMCPEngine({ sessionTimeout: 3600000, // 1 hour logLevel: 'info' }); // Handle MCP requests with per-user context app.post('/mcp', async (req, res) => { const instanceContext = { n8nApiUrl: req.user.n8nUrl, n8nApiKey: req.user.n8nApiKey, instanceId: req.user.id }; await mcpEngine.processRequest(req, res, instanceContext); }); app.listen(3000); ``` ## Multi-Tenant Backend Example This example shows a complete multi-tenant implementation with user authentication and instance management: ```typescript import express from 'express'; import { N8NMCPEngine, InstanceContext, validateInstanceContext } from 'n8n-mcp'; const app = express(); const mcpEngine = new N8NMCPEngine({ sessionTimeout: 3600000, // 1 hour logLevel: 'info' }); // Start MCP engine await mcpEngine.start(); // Authentication middleware const authenticate = async (req, res, next) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) { return res.status(401).json({ error: 'Unauthorized' }); } // Verify token and attach user to request req.user = await getUserFromToken(token); next(); }; // Get instance configuration from database const getInstanceConfig = async (instanceId: string, userId: string) => { // Your database logic here const instance = await db.instances.findOne({ where: { id: instanceId, userId } }); if (!instance) { throw new Error('Instance not found'); } return { n8nApiUrl: instance.n8nUrl, n8nApiKey: await decryptApiKey(instance.encryptedApiKey), instanceId: instance.id }; }; // MCP endpoint with per-instance context app.post('/api/instances/:instanceId/mcp', authenticate, async (req, res) => { try { // Get instance configuration const instance = await getInstanceConfig(req.params.instanceId, req.user.id); // Create instance context const context: InstanceContext = { n8nApiUrl: instance.n8nApiUrl, n8nApiKey: instance.n8nApiKey, instanceId: instance.instanceId, metadata: { userId: req.user.id, userAgent: req.headers['user-agent'], ip: req.ip } }; // Validate context before processing const validation = validateInstanceContext(context); if (!validation.valid) { return res.status(400).json({ error: 'Invalid instance configuration', details: validation.errors }); } // Process request with instance context await mcpEngine.processRequest(req, res, context); } catch (error) { console.error('MCP request error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Health endpoint app.get('/health', async (req, res) => { const health = await mcpEngine.healthCheck(); res.status(health.status === 'healthy' ? 200 : 503).json(health); }); // Graceful shutdown process.on('SIGTERM', async () => { await mcpEngine.shutdown(); process.exit(0); }); app.listen(3000); ``` ## API Reference ### N8NMCPEngine #### Constructor ```typescript new N8NMCPEngine(options?: { sessionTimeout?: number; // Session TTL in ms (default: 1800000 = 30min) logLevel?: 'error' | 'warn' | 'info' | 'debug'; // Default: 'info' }) ``` #### Methods ##### `async processRequest(req, res, context?)` Process a single MCP request with optional instance context. **Parameters:** - `req`: Express request object - `res`: Express response object - `context` (optional): InstanceContext with per-instance configuration **Example:** ```typescript const context: InstanceContext = { n8nApiUrl: 'https://instance1.n8n.cloud', n8nApiKey: 'instance1-key', instanceId: 'tenant-123' }; await engine.processRequest(req, res, context); ``` ##### `async healthCheck()` Get engine health status for monitoring. **Returns:** `EngineHealth` ```typescript { status: 'healthy' | 'unhealthy'; uptime: number; // seconds sessionActive: boolean; memoryUsage: { used: number; total: number; unit: string; }; version: string; } ``` **Example:** ```typescript app.get('/health', async (req, res) => { const health = await engine.healthCheck(); res.status(health.status === 'healthy' ? 200 : 503).json(health); }); ``` ##### `getSessionInfo()` Get current session information for debugging. **Returns:** ```typescript { active: boolean; sessionId?: string; age?: number; // milliseconds sessions?: { total: number; active: number; expired: number; max: number; sessionIds: string[]; }; } ``` ##### `async start()` Start the engine (for standalone mode). Not needed when using `processRequest()` directly. ##### `async shutdown()` Graceful shutdown for service lifecycle management. **Example:** ```typescript process.on('SIGTERM', async () => { await engine.shutdown(); process.exit(0); }); ``` ### Types #### InstanceContext Configuration for a specific user instance: ```typescript interface InstanceContext { n8nApiUrl?: string; n8nApiKey?: string; n8nApiTimeout?: number; n8nApiMaxRetries?: number; instanceId?: string; sessionId?: string; metadata?: Record<string, any>; } ``` #### Validation Functions ##### `validateInstanceContext(context: InstanceContext)` Validate and sanitize instance context. **Returns:** ```typescript { valid: boolean; errors?: string[]; } ``` **Example:** ```typescript import { validateInstanceContext } from 'n8n-mcp'; const validation = validateInstanceContext(context); if (!validation.valid) { console.error('Invalid context:', validation.errors); } ``` ##### `isInstanceContext(obj: any)` Type guard to check if an object is a valid InstanceContext. **Example:** ```typescript import { isInstanceContext } from 'n8n-mcp'; if (isInstanceContext(req.body.context)) { // TypeScript knows this is InstanceContext await engine.processRequest(req, res, req.body.context); } ``` ## Session Management ### Session Strategies The MCP engine supports flexible session ID formats: - **UUIDv4**: Internal n8n-mcp format (default) - **Instance-prefixed**: `instance-{userId}-{hash}-{uuid}` for multi-tenant isolation - **Custom formats**: Any non-empty string for mcp-remote and other proxies Session validation happens via transport lookup, not format validation. This ensures compatibility with all MCP clients. ### Multi-Tenant Configuration Set these environment variables for multi-tenant mode: ```bash # Enable multi-tenant mode ENABLE_MULTI_TENANT=true # Session strategy: "instance" (default) or "shared" MULTI_TENANT_SESSION_STRATEGY=instance ``` **Session Strategies:** - **instance** (recommended): Each tenant gets isolated sessions - Session ID: `instance-{instanceId}-{configHash}-{uuid}` - Better isolation and security - Easier debugging per tenant - **shared**: Multiple tenants share sessions with context switching - More efficient for high tenant count - Requires careful context management ## Security Considerations ### API Key Management Always encrypt API keys server-side: ```typescript import { createCipheriv, createDecipheriv } from 'crypto'; // Encrypt before storing const encryptApiKey = (apiKey: string) => { const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv); return cipher.update(apiKey, 'utf8', 'hex') + cipher.final('hex'); }; // Decrypt before using const decryptApiKey = (encrypted: string) => { const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv); return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8'); }; // Use decrypted key in context const context: InstanceContext = { n8nApiKey: await decryptApiKey(instance.encryptedApiKey), // ... }; ``` ### Input Validation Always validate instance context before processing: ```typescript import { validateInstanceContext } from 'n8n-mcp'; const validation = validateInstanceContext(context); if (!validation.valid) { throw new Error(`Invalid context: ${validation.errors?.join(', ')}`); } ``` ### Rate Limiting Implement rate limiting per tenant: ```typescript import rateLimit from 'express-rate-limit'; const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs keyGenerator: (req) => req.user?.id || req.ip }); app.post('/api/instances/:instanceId/mcp', authenticate, limiter, async (req, res) => { // ... }); ``` ## Error Handling Always wrap MCP requests in try-catch blocks: ```typescript app.post('/api/instances/:instanceId/mcp', authenticate, async (req, res) => { try { const context = await getInstanceConfig(req.params.instanceId, req.user.id); await mcpEngine.processRequest(req, res, context); } catch (error) { console.error('MCP error:', error); // Don't leak internal errors to clients if (error.message.includes('not found')) { return res.status(404).json({ error: 'Instance not found' }); } res.status(500).json({ error: 'Internal server error' }); } }); ``` ## Monitoring ### Health Checks Set up periodic health checks: ```typescript setInterval(async () => { const health = await mcpEngine.healthCheck(); if (health.status === 'unhealthy') { console.error('MCP engine unhealthy:', health); // Alert your monitoring system } // Log metrics console.log('MCP engine metrics:', { uptime: health.uptime, memory: health.memoryUsage, sessionActive: health.sessionActive }); }, 60000); // Every minute ``` ### Session Monitoring Track active sessions: ```typescript app.get('/admin/sessions', authenticate, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Forbidden' }); } const sessionInfo = mcpEngine.getSessionInfo(); res.json(sessionInfo); }); ``` ## Testing ### Unit Testing ```typescript import { N8NMCPEngine, InstanceContext } from 'n8n-mcp'; describe('MCP Engine', () => { let engine: N8NMCPEngine; beforeEach(() => { engine = new N8NMCPEngine({ logLevel: 'error' }); }); afterEach(async () => { await engine.shutdown(); }); it('should process request with context', async () => { const context: InstanceContext = { n8nApiUrl: 'https://test.n8n.io', n8nApiKey: 'test-key', instanceId: 'test-instance' }; const mockReq = createMockRequest(); const mockRes = createMockResponse(); await engine.processRequest(mockReq, mockRes, context); expect(mockRes.status).toBe(200); }); }); ``` ### Integration Testing ```typescript import request from 'supertest'; import { createApp } from './app'; describe('Multi-tenant MCP API', () => { let app; let authToken; beforeAll(async () => { app = await createApp(); authToken = await getTestAuthToken(); }); it('should handle MCP request for instance', async () => { const response = await request(app) .post('/api/instances/test-instance/mcp') .set('Authorization', `Bearer ${authToken}`) .send({ jsonrpc: '2.0', method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} }, id: 1 }); expect(response.status).toBe(200); expect(response.body.result).toBeDefined(); }); }); ``` ## Deployment Considerations ### Environment Variables ```bash # Required for multi-tenant mode ENABLE_MULTI_TENANT=true MULTI_TENANT_SESSION_STRATEGY=instance # Optional: Logging LOG_LEVEL=info DISABLE_CONSOLE_OUTPUT=false # Optional: Session configuration SESSION_TIMEOUT=1800000 # 30 minutes in milliseconds MAX_SESSIONS=100 # Optional: Performance NODE_ENV=production ``` ### Docker Deployment ```dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . ENV NODE_ENV=production ENV ENABLE_MULTI_TENANT=true ENV LOG_LEVEL=info EXPOSE 3000 CMD ["node", "dist/server.js"] ``` ### Kubernetes Deployment ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: n8n-mcp-backend spec: replicas: 3 selector: matchLabels: app: n8n-mcp-backend template: metadata: labels: app: n8n-mcp-backend spec: containers: - name: backend image: your-registry/n8n-mcp-backend:latest ports: - containerPort: 3000 env: - name: ENABLE_MULTI_TENANT value: "true" - name: LOG_LEVEL value: "info" resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "500m" livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 5 periodSeconds: 10 ``` ## Examples ### Complete Multi-Tenant SaaS Example For a complete implementation example, see: - [n8n-mcp-backend](https://github.com/czlonkowski/n8n-mcp-backend) - Full hosted service implementation ### Migration from Single-Player If you're migrating from single-player (CLI/Docker) to multi-tenant: 1. **Keep backward compatibility** - Use environment fallback: ```typescript const context: InstanceContext = { n8nApiUrl: instanceUrl || process.env.N8N_API_URL, n8nApiKey: instanceKey || process.env.N8N_API_KEY, instanceId: instanceId || 'default' }; ``` 2. **Gradual rollout** - Start with a feature flag: ```typescript const isMultiTenant = process.env.ENABLE_MULTI_TENANT === 'true'; if (isMultiTenant) { const context = await getInstanceConfig(req.params.instanceId); await engine.processRequest(req, res, context); } else { // Legacy single-player mode await engine.processRequest(req, res); } ``` ## Troubleshooting ### Common Issues #### Module Resolution Errors If you see `Cannot find module 'n8n-mcp'`: ```bash # Clear node_modules and reinstall rm -rf node_modules package-lock.json npm install # Verify package has types field npm info n8n-mcp # Check TypeScript can resolve it npx tsc --noEmit ``` #### Session ID Validation Errors If you see `Invalid session ID format` errors: - Ensure you're using n8n-mcp v2.18.9 or later - Session IDs can be any non-empty string - No need to generate UUIDs - use your own format #### Memory Leaks If memory usage grows over time: ```typescript // Ensure proper cleanup process.on('SIGTERM', async () => { await engine.shutdown(); process.exit(0); }); // Monitor session count const sessionInfo = engine.getSessionInfo(); console.log('Active sessions:', sessionInfo.sessions?.active); ``` ## Further Reading - [MCP Protocol Specification](https://modelcontextprotocol.io/docs) - [n8n API Documentation](https://docs.n8n.io/api/) - [Express.js Guide](https://expressjs.com/en/guide/routing.html) - [n8n-mcp Main README](../README.md) ## Support - **Issues**: [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues) - **Discussions**: [GitHub Discussions](https://github.com/czlonkowski/n8n-mcp/discussions) - **Security**: For security issues, see [SECURITY.md](../SECURITY.md) ``` -------------------------------------------------------------------------------- /src/utils/node-source-extractor.ts: -------------------------------------------------------------------------------- ```typescript import * as fs from 'fs/promises'; import * as path from 'path'; import { logger } from './logger'; export interface NodeSourceInfo { nodeType: string; sourceCode: string; credentialCode?: string; packageInfo?: any; location: string; } export class NodeSourceExtractor { private n8nBasePaths = [ '/usr/local/lib/node_modules/n8n/node_modules', '/app/node_modules', '/home/node/.n8n/custom/nodes', './node_modules', // Docker volume paths '/var/lib/docker/volumes/n8n-mcp_n8n_modules/_data', '/n8n-modules', // Common n8n installation paths process.env.N8N_CUSTOM_EXTENSIONS || '', // Additional local path for testing path.join(process.cwd(), 'node_modules'), ].filter(Boolean); /** * Extract source code for a specific n8n node */ async extractNodeSource(nodeType: string): Promise<NodeSourceInfo> { logger.info(`Extracting source code for node: ${nodeType}`); // Parse node type to get package and node name const { packageName, nodeName } = this.parseNodeType(nodeType); // Search for the node in known locations for (const basePath of this.n8nBasePaths) { try { const nodeInfo = await this.searchNodeInPath(basePath, packageName, nodeName); if (nodeInfo) { logger.info(`Found node source at: ${nodeInfo.location}`); return nodeInfo; } } catch (error) { logger.debug(`Failed to search in ${basePath}: ${error}`); } } throw new Error(`Node source code not found for: ${nodeType}`); } /** * Parse node type identifier */ private parseNodeType(nodeType: string): { packageName: string; nodeName: string } { // Handle different formats: // - @n8n/n8n-nodes-langchain.Agent // - n8n-nodes-base.HttpRequest // - customNode if (nodeType.includes('.')) { const [pkg, node] = nodeType.split('.'); return { packageName: pkg, nodeName: node }; } // Default to n8n-nodes-base for simple node names return { packageName: 'n8n-nodes-base', nodeName: nodeType }; } /** * Search for node in a specific path */ private async searchNodeInPath( basePath: string, packageName: string, nodeName: string ): Promise<NodeSourceInfo | null> { try { // Try both the provided case and capitalized first letter const nodeNameVariants = [ nodeName, nodeName.charAt(0).toUpperCase() + nodeName.slice(1), // Capitalize first letter nodeName.toLowerCase(), // All lowercase nodeName.toUpperCase(), // All uppercase ]; // First, try standard patterns with all case variants for (const nameVariant of nodeNameVariants) { const standardPatterns = [ `${packageName}/dist/nodes/${nameVariant}/${nameVariant}.node.js`, `${packageName}/dist/nodes/${nameVariant}.node.js`, `${packageName}/nodes/${nameVariant}/${nameVariant}.node.js`, `${packageName}/nodes/${nameVariant}.node.js`, `${nameVariant}/${nameVariant}.node.js`, `${nameVariant}.node.js`, ]; // Additional patterns for nested node structures (e.g., agents/Agent) const nestedPatterns = [ `${packageName}/dist/nodes/*/${nameVariant}/${nameVariant}.node.js`, `${packageName}/dist/nodes/**/${nameVariant}/${nameVariant}.node.js`, `${packageName}/nodes/*/${nameVariant}/${nameVariant}.node.js`, `${packageName}/nodes/**/${nameVariant}/${nameVariant}.node.js`, ]; // Try standard patterns first for (const pattern of standardPatterns) { const fullPath = path.join(basePath, pattern); const result = await this.tryLoadNodeFile(fullPath, packageName, nodeName, basePath); if (result) return result; } // Try nested patterns (with glob-like search) for (const pattern of nestedPatterns) { const result = await this.searchWithGlobPattern(basePath, pattern, packageName, nodeName); if (result) return result; } } // If basePath contains .pnpm, search in pnpm structure if (basePath.includes('node_modules')) { const pnpmPath = path.join(basePath, '.pnpm'); try { await fs.access(pnpmPath); const result = await this.searchInPnpm(pnpmPath, packageName, nodeName); if (result) return result; } catch { // .pnpm directory doesn't exist } } } catch (error) { logger.debug(`Error searching in path ${basePath}: ${error}`); } return null; } /** * Search for nodes in pnpm's special directory structure */ private async searchInPnpm( pnpmPath: string, packageName: string, nodeName: string ): Promise<NodeSourceInfo | null> { try { const entries = await fs.readdir(pnpmPath); // Filter entries that might contain our package const packageEntries = entries.filter(entry => entry.includes(packageName.replace('/', '+')) || entry.includes(packageName) ); for (const entry of packageEntries) { const entryPath = path.join(pnpmPath, entry, 'node_modules', packageName); // Search patterns within the pnpm package directory const patterns = [ `dist/nodes/${nodeName}/${nodeName}.node.js`, `dist/nodes/${nodeName}.node.js`, `dist/nodes/*/${nodeName}/${nodeName}.node.js`, `dist/nodes/**/${nodeName}/${nodeName}.node.js`, ]; for (const pattern of patterns) { if (pattern.includes('*')) { const result = await this.searchWithGlobPattern(entryPath, pattern, packageName, nodeName); if (result) return result; } else { const fullPath = path.join(entryPath, pattern); const result = await this.tryLoadNodeFile(fullPath, packageName, nodeName, entryPath); if (result) return result; } } } } catch (error) { logger.debug(`Error searching in pnpm directory: ${error}`); } return null; } /** * Search for files matching a glob-like pattern */ private async searchWithGlobPattern( basePath: string, pattern: string, packageName: string, nodeName: string ): Promise<NodeSourceInfo | null> { // Convert glob pattern to regex parts const parts = pattern.split('/'); const targetFile = `${nodeName}.node.js`; async function searchDir(currentPath: string, remainingParts: string[]): Promise<string | null> { if (remainingParts.length === 0) return null; const part = remainingParts[0]; const isLastPart = remainingParts.length === 1; try { if (isLastPart && part === targetFile) { // Check if file exists const fullPath = path.join(currentPath, part); await fs.access(fullPath); return fullPath; } const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() && !isLastPart) continue; if (part === '*' || part === '**') { // Match any directory if (entry.isDirectory()) { const result = await searchDir( path.join(currentPath, entry.name), part === '**' ? remainingParts : remainingParts.slice(1) ); if (result) return result; } } else if (entry.name === part || (isLastPart && entry.name === targetFile)) { if (isLastPart && entry.isFile()) { return path.join(currentPath, entry.name); } else if (!isLastPart && entry.isDirectory()) { const result = await searchDir( path.join(currentPath, entry.name), remainingParts.slice(1) ); if (result) return result; } } } } catch { // Directory doesn't exist or can't be read } return null; } const foundPath = await searchDir(basePath, parts); if (foundPath) { return this.tryLoadNodeFile(foundPath, packageName, nodeName, basePath); } return null; } /** * Try to load a node file and its associated files */ private async tryLoadNodeFile( fullPath: string, packageName: string, nodeName: string, packageBasePath: string ): Promise<NodeSourceInfo | null> { try { const sourceCode = await fs.readFile(fullPath, 'utf-8'); // Try to find credential files let credentialCode: string | undefined; // First, try alongside the node file const credentialPath = fullPath.replace('.node.js', '.credentials.js'); try { credentialCode = await fs.readFile(credentialPath, 'utf-8'); } catch { // Try in the credentials directory const possibleCredentialPaths = [ // Standard n8n structure: dist/credentials/NodeNameApi.credentials.js path.join(packageBasePath, packageName, 'dist/credentials', `${nodeName}Api.credentials.js`), path.join(packageBasePath, packageName, 'dist/credentials', `${nodeName}OAuth2Api.credentials.js`), path.join(packageBasePath, packageName, 'credentials', `${nodeName}Api.credentials.js`), path.join(packageBasePath, packageName, 'credentials', `${nodeName}OAuth2Api.credentials.js`), // Without packageName in path path.join(packageBasePath, 'dist/credentials', `${nodeName}Api.credentials.js`), path.join(packageBasePath, 'dist/credentials', `${nodeName}OAuth2Api.credentials.js`), path.join(packageBasePath, 'credentials', `${nodeName}Api.credentials.js`), path.join(packageBasePath, 'credentials', `${nodeName}OAuth2Api.credentials.js`), // Try relative to node location path.join(path.dirname(path.dirname(fullPath)), 'credentials', `${nodeName}Api.credentials.js`), path.join(path.dirname(path.dirname(fullPath)), 'credentials', `${nodeName}OAuth2Api.credentials.js`), path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'credentials', `${nodeName}Api.credentials.js`), path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'credentials', `${nodeName}OAuth2Api.credentials.js`), ]; // Try to find any credential file const allCredentials: string[] = []; for (const credPath of possibleCredentialPaths) { try { const content = await fs.readFile(credPath, 'utf-8'); allCredentials.push(content); logger.debug(`Found credential file at: ${credPath}`); } catch { // Continue searching } } // If we found credentials, combine them if (allCredentials.length > 0) { credentialCode = allCredentials.join('\n\n// --- Next Credential File ---\n\n'); } } // Try to get package.json info let packageInfo: any; const possiblePackageJsonPaths = [ path.join(packageBasePath, 'package.json'), path.join(packageBasePath, packageName, 'package.json'), path.join(path.dirname(path.dirname(fullPath)), 'package.json'), path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'package.json'), // Try to go up from the node location to find package.json path.join(fullPath.split('/dist/')[0], 'package.json'), path.join(fullPath.split('/nodes/')[0], 'package.json'), ]; for (const packageJsonPath of possiblePackageJsonPaths) { try { const packageJson = await fs.readFile(packageJsonPath, 'utf-8'); packageInfo = JSON.parse(packageJson); logger.debug(`Found package.json at: ${packageJsonPath}`); break; } catch { // Try next path } } return { nodeType: `${packageName}.${nodeName}`, sourceCode, credentialCode, packageInfo, location: fullPath, }; } catch { return null; } } /** * List all available nodes */ async listAvailableNodes(category?: string, search?: string): Promise<any[]> { const nodes: any[] = []; const seenNodes = new Set<string>(); // Track unique nodes for (const basePath of this.n8nBasePaths) { try { // Check for n8n-nodes-base specifically const n8nNodesBasePath = path.join(basePath, 'n8n-nodes-base', 'dist', 'nodes'); try { await fs.access(n8nNodesBasePath); await this.scanDirectoryForNodes(n8nNodesBasePath, nodes, category, search, seenNodes); } catch { // Try without dist const altPath = path.join(basePath, 'n8n-nodes-base', 'nodes'); try { await fs.access(altPath); await this.scanDirectoryForNodes(altPath, nodes, category, search, seenNodes); } catch { // Try the base path directly await this.scanDirectoryForNodes(basePath, nodes, category, search, seenNodes); } } } catch (error) { logger.debug(`Failed to scan ${basePath}: ${error}`); } } return nodes; } /** * Scan directory for n8n nodes */ private async scanDirectoryForNodes( dirPath: string, nodes: any[], category?: string, search?: string, seenNodes?: Set<string> ): Promise<void> { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && entry.name.endsWith('.node.js')) { try { const fullPath = path.join(dirPath, entry.name); const content = await fs.readFile(fullPath, 'utf-8'); // Extract basic info from the source const nameMatch = content.match(/displayName:\s*['"`]([^'"`]+)['"`]/); const descriptionMatch = content.match(/description:\s*['"`]([^'"`]+)['"`]/); if (nameMatch) { const nodeName = entry.name.replace('.node.js', ''); // Skip if we've already seen this node if (seenNodes && seenNodes.has(nodeName)) { continue; } const nodeInfo = { name: nodeName, displayName: nameMatch[1], description: descriptionMatch ? descriptionMatch[1] : '', location: fullPath, }; // Apply filters if (category && !nodeInfo.displayName.toLowerCase().includes(category.toLowerCase())) { continue; } if (search && !nodeInfo.displayName.toLowerCase().includes(search.toLowerCase()) && !nodeInfo.description.toLowerCase().includes(search.toLowerCase())) { continue; } nodes.push(nodeInfo); if (seenNodes) { seenNodes.add(nodeName); } } } catch { // Skip files we can't read } } else if (entry.isDirectory()) { // Special handling for .pnpm directories if (entry.name === '.pnpm') { await this.scanPnpmDirectory(path.join(dirPath, entry.name), nodes, category, search, seenNodes); } else if (entry.name !== 'node_modules') { // Recursively scan subdirectories await this.scanDirectoryForNodes(path.join(dirPath, entry.name), nodes, category, search, seenNodes); } } } } catch (error) { logger.debug(`Error scanning directory ${dirPath}: ${error}`); } } /** * Scan pnpm directory structure for nodes */ private async scanPnpmDirectory( pnpmPath: string, nodes: any[], category?: string, search?: string, seenNodes?: Set<string> ): Promise<void> { try { const entries = await fs.readdir(pnpmPath); for (const entry of entries) { const entryPath = path.join(pnpmPath, entry, 'node_modules'); try { await fs.access(entryPath); await this.scanDirectoryForNodes(entryPath, nodes, category, search, seenNodes); } catch { // Skip if node_modules doesn't exist } } } catch (error) { logger.debug(`Error scanning pnpm directory ${pnpmPath}: ${error}`); } } /** * Extract AI Agent node specifically */ async extractAIAgentNode(): Promise<NodeSourceInfo> { // AI Agent is typically in @n8n/n8n-nodes-langchain package return this.extractNodeSource('@n8n/n8n-nodes-langchain.Agent'); } } ``` -------------------------------------------------------------------------------- /tests/unit/__mocks__/n8n-nodes-base.ts: -------------------------------------------------------------------------------- ```typescript import { vi } from 'vitest'; // Mock types that match n8n-workflow interface INodeExecutionData { json: any; binary?: any; pairedItem?: any; } interface IExecuteFunctions { getInputData(): INodeExecutionData[]; getNodeParameter(parameterName: string, itemIndex: number, fallbackValue?: any): any; getCredentials(type: string): Promise<any>; helpers: { returnJsonArray(data: any): INodeExecutionData[]; httpRequest(options: any): Promise<any>; webhook(): any; }; } interface IWebhookFunctions { getWebhookName(): string; getBodyData(): any; getHeaderData(): any; getQueryData(): any; getRequestObject(): any; getResponseObject(): any; helpers: { returnJsonArray(data: any): INodeExecutionData[]; }; } interface INodeTypeDescription { displayName: string; name: string; group: string[]; version: number; description: string; defaults: { name: string }; inputs: string[]; outputs: string[]; credentials?: any[]; webhooks?: any[]; properties: any[]; icon?: string; subtitle?: string; } interface INodeType { description: INodeTypeDescription; execute?(this: IExecuteFunctions): Promise<INodeExecutionData[][]>; webhook?(this: IWebhookFunctions): Promise<any>; trigger?(this: any): Promise<void>; poll?(this: any): Promise<INodeExecutionData[][] | null>; } // Base mock node implementation class BaseMockNode implements INodeType { description: INodeTypeDescription; execute: any; webhook: any; constructor(description: INodeTypeDescription, execute?: any, webhook?: any) { this.description = description; this.execute = execute ? vi.fn(execute) : undefined; this.webhook = webhook ? vi.fn(webhook) : undefined; } } // Mock implementations for each node type const mockWebhookNode = new BaseMockNode( { displayName: 'Webhook', name: 'webhook', group: ['trigger'], version: 1, description: 'Starts the workflow when a webhook is called', defaults: { name: 'Webhook' }, inputs: [], outputs: ['main'], webhooks: [ { name: 'default', httpMethod: '={{$parameter["httpMethod"]}}', path: '={{$parameter["path"]}}', responseMode: '={{$parameter["responseMode"]}}', } ], properties: [ { displayName: 'Path', name: 'path', type: 'string', default: 'webhook', required: true, description: 'The path to listen on', }, { displayName: 'HTTP Method', name: 'httpMethod', type: 'options', default: 'GET', options: [ { name: 'GET', value: 'GET' }, { name: 'POST', value: 'POST' }, { name: 'PUT', value: 'PUT' }, { name: 'DELETE', value: 'DELETE' }, { name: 'HEAD', value: 'HEAD' }, { name: 'PATCH', value: 'PATCH' }, ], }, { displayName: 'Response Mode', name: 'responseMode', type: 'options', default: 'onReceived', options: [ { name: 'On Received', value: 'onReceived' }, { name: 'Last Node', value: 'lastNode' }, ], }, ], }, undefined, async function webhook(this: IWebhookFunctions) { const returnData: INodeExecutionData[] = []; returnData.push({ json: { headers: this.getHeaderData(), params: this.getQueryData(), body: this.getBodyData(), } }); return { workflowData: [returnData], }; } ); const mockHttpRequestNode = new BaseMockNode( { displayName: 'HTTP Request', name: 'httpRequest', group: ['transform'], version: 3, description: 'Makes an HTTP request and returns the response', defaults: { name: 'HTTP Request' }, inputs: ['main'], outputs: ['main'], properties: [ { displayName: 'Method', name: 'method', type: 'options', default: 'GET', options: [ { name: 'GET', value: 'GET' }, { name: 'POST', value: 'POST' }, { name: 'PUT', value: 'PUT' }, { name: 'DELETE', value: 'DELETE' }, { name: 'HEAD', value: 'HEAD' }, { name: 'PATCH', value: 'PATCH' }, ], }, { displayName: 'URL', name: 'url', type: 'string', default: '', required: true, placeholder: 'https://example.com', }, { displayName: 'Authentication', name: 'authentication', type: 'options', default: 'none', options: [ { name: 'None', value: 'none' }, { name: 'Basic Auth', value: 'basicAuth' }, { name: 'Digest Auth', value: 'digestAuth' }, { name: 'Header Auth', value: 'headerAuth' }, { name: 'OAuth1', value: 'oAuth1' }, { name: 'OAuth2', value: 'oAuth2' }, ], }, { displayName: 'Response Format', name: 'responseFormat', type: 'options', default: 'json', options: [ { name: 'JSON', value: 'json' }, { name: 'String', value: 'string' }, { name: 'File', value: 'file' }, ], }, { displayName: 'Options', name: 'options', type: 'collection', placeholder: 'Add Option', default: {}, options: [ { displayName: 'Body Content Type', name: 'bodyContentType', type: 'options', default: 'json', options: [ { name: 'JSON', value: 'json' }, { name: 'Form Data', value: 'formData' }, { name: 'Form URL Encoded', value: 'form-urlencoded' }, { name: 'Raw', value: 'raw' }, ], }, { displayName: 'Headers', name: 'headers', type: 'fixedCollection', default: {}, typeOptions: { multipleValues: true, }, }, { displayName: 'Query Parameters', name: 'queryParameters', type: 'fixedCollection', default: {}, typeOptions: { multipleValues: true, }, }, ], }, ], }, async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; for (let i = 0; i < items.length; i++) { const method = this.getNodeParameter('method', i) as string; const url = this.getNodeParameter('url', i) as string; // Mock response const response = { statusCode: 200, headers: {}, body: { success: true, method, url }, }; returnData.push({ json: response, }); } return [returnData]; } ); const mockSlackNode = new BaseMockNode( { displayName: 'Slack', name: 'slack', group: ['output'], version: 2, description: 'Send messages to Slack', defaults: { name: 'Slack' }, inputs: ['main'], outputs: ['main'], credentials: [ { name: 'slackApi', required: true, }, ], properties: [ { displayName: 'Resource', name: 'resource', type: 'options', default: 'message', options: [ { name: 'Channel', value: 'channel' }, { name: 'Message', value: 'message' }, { name: 'User', value: 'user' }, { name: 'File', value: 'file' }, ], }, { displayName: 'Operation', name: 'operation', type: 'options', displayOptions: { show: { resource: ['message'], }, }, default: 'post', options: [ { name: 'Post', value: 'post' }, { name: 'Update', value: 'update' }, { name: 'Delete', value: 'delete' }, ], }, { displayName: 'Channel', name: 'channel', type: 'options', typeOptions: { loadOptionsMethod: 'getChannels', }, displayOptions: { show: { resource: ['message'], operation: ['post'], }, }, default: '', required: true, }, { displayName: 'Text', name: 'text', type: 'string', typeOptions: { alwaysOpenEditWindow: true, }, displayOptions: { show: { resource: ['message'], operation: ['post'], }, }, default: '', required: true, }, ], }, async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; for (let i = 0; i < items.length; i++) { const resource = this.getNodeParameter('resource', i) as string; const operation = this.getNodeParameter('operation', i) as string; // Mock response const response = { ok: true, channel: this.getNodeParameter('channel', i, '') as string, ts: Date.now().toString(), message: { text: this.getNodeParameter('text', i, '') as string, }, }; returnData.push({ json: response, }); } return [returnData]; } ); const mockFunctionNode = new BaseMockNode( { displayName: 'Function', name: 'function', group: ['transform'], version: 1, description: 'Execute custom JavaScript code', defaults: { name: 'Function' }, inputs: ['main'], outputs: ['main'], properties: [ { displayName: 'JavaScript Code', name: 'functionCode', type: 'string', typeOptions: { alwaysOpenEditWindow: true, codeAutocomplete: 'function', editor: 'code', rows: 10, }, default: 'return items;', description: 'JavaScript code to execute', }, ], }, async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { const items = this.getInputData(); const functionCode = this.getNodeParameter('functionCode', 0) as string; // Simple mock - just return items return [items]; } ); const mockNoOpNode = new BaseMockNode( { displayName: 'No Operation', name: 'noOp', group: ['utility'], version: 1, description: 'Does nothing', defaults: { name: 'No Op' }, inputs: ['main'], outputs: ['main'], properties: [], }, async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { return [this.getInputData()]; } ); const mockMergeNode = new BaseMockNode( { displayName: 'Merge', name: 'merge', group: ['transform'], version: 2, description: 'Merge multiple data streams', defaults: { name: 'Merge' }, inputs: ['main', 'main'], outputs: ['main'], properties: [ { displayName: 'Mode', name: 'mode', type: 'options', default: 'append', options: [ { name: 'Append', value: 'append' }, { name: 'Merge By Index', value: 'mergeByIndex' }, { name: 'Merge By Key', value: 'mergeByKey' }, { name: 'Multiplex', value: 'multiplex' }, ], }, ], }, async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { const mode = this.getNodeParameter('mode', 0) as string; // Mock merge - just return first input return [this.getInputData()]; } ); const mockIfNode = new BaseMockNode( { displayName: 'IF', name: 'if', group: ['transform'], version: 1, description: 'Conditional logic', defaults: { name: 'IF' }, inputs: ['main'], outputs: ['main', 'main'], // outputNames: ['true', 'false'], // Not a valid property in INodeTypeDescription properties: [ { displayName: 'Conditions', name: 'conditions', type: 'fixedCollection', typeOptions: { multipleValues: true, }, default: {}, options: [ { name: 'string', displayName: 'String', values: [ { displayName: 'Value 1', name: 'value1', type: 'string', default: '', }, { displayName: 'Operation', name: 'operation', type: 'options', default: 'equals', options: [ { name: 'Equals', value: 'equals' }, { name: 'Not Equals', value: 'notEquals' }, { name: 'Contains', value: 'contains' }, { name: 'Not Contains', value: 'notContains' }, ], }, { displayName: 'Value 2', name: 'value2', type: 'string', default: '', }, ], }, ], }, ], }, async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { const items = this.getInputData(); const trueItems: INodeExecutionData[] = []; const falseItems: INodeExecutionData[] = []; // Mock condition - split 50/50 items.forEach((item, index) => { if (index % 2 === 0) { trueItems.push(item); } else { falseItems.push(item); } }); return [trueItems, falseItems]; } ); const mockSwitchNode = new BaseMockNode( { displayName: 'Switch', name: 'switch', group: ['transform'], version: 1, description: 'Route items based on conditions', defaults: { name: 'Switch' }, inputs: ['main'], outputs: ['main', 'main', 'main', 'main'], properties: [ { displayName: 'Mode', name: 'mode', type: 'options', default: 'expression', options: [ { name: 'Expression', value: 'expression' }, { name: 'Rules', value: 'rules' }, ], }, { displayName: 'Output', name: 'output', type: 'options', displayOptions: { show: { mode: ['expression'], }, }, default: 'all', options: [ { name: 'All', value: 'all' }, { name: 'First Match', value: 'firstMatch' }, ], }, ], }, async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { const items = this.getInputData(); // Mock routing - distribute evenly across outputs const outputs: INodeExecutionData[][] = [[], [], [], []]; items.forEach((item, index) => { outputs[index % 4].push(item); }); return outputs; } ); // Node registry const nodeRegistry = new Map<string, INodeType>([ ['webhook', mockWebhookNode], ['httpRequest', mockHttpRequestNode], ['slack', mockSlackNode], ['function', mockFunctionNode], ['noOp', mockNoOpNode], ['merge', mockMergeNode], ['if', mockIfNode], ['switch', mockSwitchNode], ]); // Export mock functions export const getNodeTypes = vi.fn(() => ({ getByName: vi.fn((name: string) => nodeRegistry.get(name)), getByNameAndVersion: vi.fn((name: string, version: number) => nodeRegistry.get(name)), })); // Export individual node classes for direct import export const Webhook = mockWebhookNode; export const HttpRequest = mockHttpRequestNode; export const Slack = mockSlackNode; export const Function = mockFunctionNode; export const NoOp = mockNoOpNode; export const Merge = mockMergeNode; export const If = mockIfNode; export const Switch = mockSwitchNode; // Test utility to override node behavior export const mockNodeBehavior = (nodeName: string, overrides: Partial<INodeType>) => { const existingNode = nodeRegistry.get(nodeName); if (!existingNode) { throw new Error(`Node ${nodeName} not found in registry`); } const updatedNode = new BaseMockNode( { ...existingNode.description, ...overrides.description }, overrides.execute || existingNode.execute, overrides.webhook || existingNode.webhook ); nodeRegistry.set(nodeName, updatedNode); return updatedNode; }; // Test utility to reset all mocks export const resetAllMocks = () => { getNodeTypes.mockClear(); nodeRegistry.forEach((node) => { if (node.execute && vi.isMockFunction(node.execute)) { node.execute.mockClear(); } if (node.webhook && vi.isMockFunction(node.webhook)) { node.webhook.mockClear(); } }); }; // Test utility to add custom nodes export const registerMockNode = (name: string, node: INodeType) => { nodeRegistry.set(name, node); }; // Export default for require() compatibility export default { getNodeTypes, Webhook, HttpRequest, Slack, Function, NoOp, Merge, If, Switch, mockNodeBehavior, resetAllMocks, registerMockNode, }; ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/workflow-error-validation.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { TestableN8NMCPServer } from './test-helpers'; describe('MCP Workflow Error Output Validation Integration', () => { let mcpServer: TestableN8NMCPServer; let client: Client; beforeEach(async () => { mcpServer = new TestableN8NMCPServer(); await mcpServer.initialize(); const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); await mcpServer.connectToTransport(serverTransport); client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); await client.connect(clientTransport); }); afterEach(async () => { await client.close(); await mcpServer.close(); }); describe('validate_workflow tool - Error Output Configuration', () => { it('should detect incorrect error output configuration via MCP', async () => { const workflow = { nodes: [ { id: '1', name: 'Validate Input', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [-400, 64], parameters: {} }, { id: '2', name: 'Filter URLs', type: 'n8n-nodes-base.filter', typeVersion: 2.2, position: [-176, 64], parameters: {} }, { id: '3', name: 'Error Response1', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.5, position: [-160, 240], parameters: {} } ], connections: { 'Validate Input': { main: [ [ { node: 'Filter URLs', type: 'main', index: 0 }, { node: 'Error Response1', type: 'main', index: 0 } // WRONG! Both in main[0] ] ] } } }; const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow } }); expect((response as any).content).toHaveLength(1); expect((response as any).content[0].type).toBe('text'); const result = JSON.parse(((response as any).content[0]).text); expect(result.valid).toBe(false); expect(Array.isArray(result.errors)).toBe(true); // Check for the specific error message about incorrect configuration const hasIncorrectConfigError = result.errors.some((e: any) => e.message.includes('Incorrect error output configuration') && e.message.includes('Error Response1') && e.message.includes('appear to be error handlers but are in main[0]') ); expect(hasIncorrectConfigError).toBe(true); // Verify the error message includes the JSON examples const errorMsg = result.errors.find((e: any) => e.message.includes('Incorrect error output configuration') ); expect(errorMsg?.message).toContain('INCORRECT (current)'); expect(errorMsg?.message).toContain('CORRECT (should be)'); expect(errorMsg?.message).toContain('main[1] = error output'); }); it('should validate correct error output configuration via MCP', async () => { const workflow = { nodes: [ { id: '1', name: 'Validate Input', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [-400, 64], parameters: {}, onError: 'continueErrorOutput' }, { id: '2', name: 'Filter URLs', type: 'n8n-nodes-base.filter', typeVersion: 2.2, position: [-176, 64], parameters: {} }, { id: '3', name: 'Error Response1', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.5, position: [-160, 240], parameters: {} } ], connections: { 'Validate Input': { main: [ [ { node: 'Filter URLs', type: 'main', index: 0 } ], [ { node: 'Error Response1', type: 'main', index: 0 } // Correctly in main[1] ] ] } } }; const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow } }); expect((response as any).content).toHaveLength(1); expect((response as any).content[0].type).toBe('text'); const result = JSON.parse(((response as any).content[0]).text); // Should not have the specific error about incorrect configuration const hasIncorrectConfigError = result.errors?.some((e: any) => e.message.includes('Incorrect error output configuration') ) ?? false; expect(hasIncorrectConfigError).toBe(false); }); it('should detect onError and connection mismatches via MCP', async () => { // Test case 1: onError set but no error connections const workflow1 = { nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [100, 100], parameters: {}, onError: 'continueErrorOutput' }, { id: '2', name: 'Process Data', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: { 'HTTP Request': { main: [ [ { node: 'Process Data', type: 'main', index: 0 } ] ] } } }; // Test case 2: error connections but no onError const workflow2 = { nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [100, 100], parameters: {} // No onError property }, { id: '2', name: 'Process Data', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} } ], connections: { 'HTTP Request': { main: [ [ { node: 'Process Data', type: 'main', index: 0 } ], [ { node: 'Error Handler', type: 'main', index: 0 } ] ] } } }; // Test both scenarios const workflows = [workflow1, workflow2]; for (const workflow of workflows) { const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow } }); const result = JSON.parse(((response as any).content[0]).text); // Should detect some kind of validation issue expect(result).toHaveProperty('valid'); expect(Array.isArray(result.errors || [])).toBe(true); expect(Array.isArray(result.warnings || [])).toBe(true); } }); it('should handle large workflows with complex error patterns via MCP', async () => { // Create a large workflow with multiple error handling scenarios const nodes = []; const connections: any = {}; // Create 50 nodes with various error handling patterns for (let i = 1; i <= 50; i++) { nodes.push({ id: i.toString(), name: `Node${i}`, type: i % 5 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set', typeVersion: 1, position: [i * 100, 100], parameters: {}, ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {}) }); } // Create connections with mixed correct and incorrect error handling for (let i = 1; i < 50; i++) { const hasErrorHandling = i % 3 === 0; const nextNode = `Node${i + 1}`; if (hasErrorHandling && i % 6 === 0) { // Incorrect: error handler in main[0] with success node connections[`Node${i}`] = { main: [ [ { node: nextNode, type: 'main', index: 0 }, { node: 'Error Handler', type: 'main', index: 0 } // Wrong placement ] ] }; } else if (hasErrorHandling) { // Correct: separate success and error outputs connections[`Node${i}`] = { main: [ [ { node: nextNode, type: 'main', index: 0 } ], [ { node: 'Error Handler', type: 'main', index: 0 } ] ] }; } else { // Normal connection connections[`Node${i}`] = { main: [ [ { node: nextNode, type: 'main', index: 0 } ] ] }; } } // Add error handler node nodes.push({ id: '51', name: 'Error Handler', type: 'n8n-nodes-base.set', typeVersion: 1, position: [2600, 200], parameters: {} }); const workflow = { nodes, connections }; const startTime = Date.now(); const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow } }); const endTime = Date.now(); // Validation should complete quickly even for large workflows expect(endTime - startTime).toBeLessThan(5000); // Less than 5 seconds const result = JSON.parse(((response as any).content[0]).text); // Should detect the incorrect error configurations const hasErrors = result.errors && result.errors.length > 0; expect(hasErrors).toBe(true); // Specifically check for incorrect error output configuration errors const incorrectConfigErrors = result.errors.filter((e: any) => e.message.includes('Incorrect error output configuration') ); expect(incorrectConfigErrors.length).toBeGreaterThan(0); }); it('should handle edge cases gracefully via MCP', async () => { const edgeCaseWorkflows = [ // Empty workflow { nodes: [], connections: {} }, // Single isolated node { nodes: [{ id: '1', name: 'Isolated', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }], connections: {} }, // Node with null/undefined connections { nodes: [{ id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }], connections: { 'Source': { main: [null, undefined] } } } ]; for (const workflow of edgeCaseWorkflows) { const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow } }); expect((response as any).content).toHaveLength(1); const result = JSON.parse(((response as any).content[0]).text); // Should not crash and should return a valid validation result expect(result).toHaveProperty('valid'); expect(typeof result.valid).toBe('boolean'); expect(Array.isArray(result.errors || [])).toBe(true); expect(Array.isArray(result.warnings || [])).toBe(true); } }); it('should validate with different validation profiles via MCP', async () => { const workflow = { nodes: [ { id: '1', name: 'API Call', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, { id: '2', name: 'Success Handler', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, { id: '3', name: 'Error Response', type: 'n8n-nodes-base.respondToWebhook', position: [300, 200], parameters: {} } ], connections: { 'API Call': { main: [ [ { node: 'Success Handler', type: 'main', index: 0 }, { node: 'Error Response', type: 'main', index: 0 } // Incorrect placement ] ] } } }; const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict']; for (const profile of profiles) { const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow, options: { profile } } }); const result = JSON.parse(((response as any).content[0]).text); // All profiles should detect this error output configuration issue const hasIncorrectConfigError = result.errors?.some((e: any) => e.message.includes('Incorrect error output configuration') ); expect(hasIncorrectConfigError).toBe(true); } }); }); describe('Error Message Format Consistency', () => { it('should format error messages consistently across different scenarios', async () => { const scenarios = [ { name: 'Single error handler in wrong place', workflow: { nodes: [ { id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} } ], connections: { 'Source': { main: [[ { node: 'Success', type: 'main', index: 0 }, { node: 'Error Handler', type: 'main', index: 0 } ]] } } } }, { name: 'Multiple error handlers in wrong place', workflow: { nodes: [ { id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'Error Handler 1', type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} }, { id: '4', name: 'Error Handler 2', type: 'n8n-nodes-base.emailSend', position: [200, 200], parameters: {} } ], connections: { 'Source': { main: [[ { node: 'Success', type: 'main', index: 0 }, { node: 'Error Handler 1', type: 'main', index: 0 }, { node: 'Error Handler 2', type: 'main', index: 0 } ]] } } } } ]; for (const scenario of scenarios) { const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow: scenario.workflow } }); const result = JSON.parse(((response as any).content[0]).text); const errorConfigError = result.errors.find((e: any) => e.message.includes('Incorrect error output configuration') ); expect(errorConfigError).toBeDefined(); // Check that error message follows consistent format expect(errorConfigError.message).toContain('INCORRECT (current):'); expect(errorConfigError.message).toContain('CORRECT (should be):'); expect(errorConfigError.message).toContain('main[0] = success output'); expect(errorConfigError.message).toContain('main[1] = error output'); expect(errorConfigError.message).toContain('Also add: "onError": "continueErrorOutput"'); // Check JSON format is valid const incorrectSection = errorConfigError.message.match(/INCORRECT \(current\):\n([\s\S]*?)\n\nCORRECT/); const correctSection = errorConfigError.message.match(/CORRECT \(should be\):\n([\s\S]*?)\n\nAlso add/); expect(incorrectSection).toBeDefined(); expect(correctSection).toBeDefined(); // Verify JSON structure is present (but don't parse due to comments) expect(incorrectSection).toBeDefined(); expect(correctSection).toBeDefined(); expect(incorrectSection![1]).toContain('main'); expect(correctSection![1]).toContain('main'); } }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-performance.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { WorkflowValidator } from '@/services/workflow-validator'; import { NodeRepository } from '@/database/node-repository'; import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; vi.mock('@/utils/logger'); describe('WorkflowValidator - Performance Tests', () => { let validator: WorkflowValidator; let mockNodeRepository: any; beforeEach(() => { vi.clearAllMocks(); // Create mock repository with performance optimizations mockNodeRepository = { getNode: vi.fn((type: string) => { // Return mock node info for any node type to avoid database calls return { node_type: type, display_name: 'Mock Node', isVersioned: true, version: 1 }; }) }; validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); }); describe('Large Workflow Performance', () => { it('should validate large workflows with many error paths efficiently', async () => { // Generate a large workflow with 500 nodes const nodeCount = 500; const nodes = []; const connections: any = {}; // Create nodes with various error handling patterns for (let i = 1; i <= nodeCount; i++) { nodes.push({ id: i.toString(), name: `Node${i}`, type: i % 5 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set', typeVersion: 1, position: [i * 10, (i % 10) * 100], parameters: {}, ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {}) }); } // Create connections with multiple error handling scenarios for (let i = 1; i < nodeCount; i++) { const hasErrorHandling = i % 3 === 0; const hasMultipleConnections = i % 7 === 0; if (hasErrorHandling && hasMultipleConnections) { // Mix correct and incorrect error handling patterns const isIncorrect = i % 14 === 0; if (isIncorrect) { // Incorrect: error handlers mixed with success nodes in main[0] connections[`Node${i}`] = { main: [ [ { node: `Node${i + 1}`, type: 'main', index: 0 }, { node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong! ] ] }; } else { // Correct: separate success and error outputs connections[`Node${i}`] = { main: [ [ { node: `Node${i + 1}`, type: 'main', index: 0 } ], [ { node: `Error Handler ${i}`, type: 'main', index: 0 } ] ] }; } // Add error handler node nodes.push({ id: `error-${i}`, name: `Error Handler ${i}`, type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1, position: [(i + nodeCount) * 10, 500], parameters: {} }); } else { // Simple connection connections[`Node${i}`] = { main: [ [ { node: `Node${i + 1}`, type: 'main', index: 0 } ] ] }; } } const workflow = { nodes, connections }; const startTime = performance.now(); const result = await validator.validateWorkflow(workflow as any); const endTime = performance.now(); const executionTime = endTime - startTime; // Validation should complete within reasonable time expect(executionTime).toBeLessThan(10000); // Less than 10 seconds // Should still catch validation errors expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.warnings)).toBe(true); // Should detect incorrect error configurations const incorrectConfigErrors = result.errors.filter(e => e.message.includes('Incorrect error output configuration') ); expect(incorrectConfigErrors.length).toBeGreaterThan(0); console.log(`Validated ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`); console.log(`Found ${result.errors.length} errors and ${result.warnings.length} warnings`); }); it('should handle deeply nested error handling chains efficiently', async () => { // Create a chain of error handlers, each with their own error handling const chainLength = 100; const nodes = []; const connections: any = {}; for (let i = 1; i <= chainLength; i++) { // Main processing node nodes.push({ id: `main-${i}`, name: `Main ${i}`, type: 'n8n-nodes-base.httpRequest', typeVersion: 1, position: [i * 150, 100], parameters: {}, onError: 'continueErrorOutput' }); // Error handler node nodes.push({ id: `error-${i}`, name: `Error Handler ${i}`, type: 'n8n-nodes-base.httpRequest', typeVersion: 1, position: [i * 150, 300], parameters: {}, onError: 'continueErrorOutput' }); // Fallback error node nodes.push({ id: `fallback-${i}`, name: `Fallback ${i}`, type: 'n8n-nodes-base.set', typeVersion: 1, position: [i * 150, 500], parameters: {} }); // Connections connections[`Main ${i}`] = { main: [ // Success path i < chainLength ? [{ node: `Main ${i + 1}`, type: 'main', index: 0 }] : [], // Error path [{ node: `Error Handler ${i}`, type: 'main', index: 0 }] ] }; connections[`Error Handler ${i}`] = { main: [ // Success path (continue to next error handler or end) [], // Error path (go to fallback) [{ node: `Fallback ${i}`, type: 'main', index: 0 }] ] }; } const workflow = { nodes, connections }; const startTime = performance.now(); const result = await validator.validateWorkflow(workflow as any); const endTime = performance.now(); const executionTime = endTime - startTime; // Should complete quickly even with complex nested error handling expect(executionTime).toBeLessThan(5000); // Less than 5 seconds // Should not have errors about incorrect configuration (this is correct) const incorrectConfigErrors = result.errors.filter(e => e.message.includes('Incorrect error output configuration') ); expect(incorrectConfigErrors.length).toBe(0); console.log(`Validated ${nodes.length} nodes with nested error handling in ${executionTime.toFixed(2)}ms`); }); it('should efficiently validate workflows with many parallel error paths', async () => { // Create a workflow with one source node that fans out to many parallel paths, // each with their own error handling const parallelPathCount = 200; const nodes = [ { id: 'source', name: 'Source', type: 'n8n-nodes-base.webhook', typeVersion: 1, position: [0, 0], parameters: {} } ]; const connections: any = { 'Source': { main: [[]] } }; // Create parallel paths for (let i = 1; i <= parallelPathCount; i++) { // Processing node nodes.push({ id: `process-${i}`, name: `Process ${i}`, type: 'n8n-nodes-base.httpRequest', typeVersion: 1, position: [200, i * 20], parameters: {}, onError: 'continueErrorOutput' } as any); // Success handler nodes.push({ id: `success-${i}`, name: `Success ${i}`, type: 'n8n-nodes-base.set', typeVersion: 1, position: [400, i * 20], parameters: {} }); // Error handler nodes.push({ id: `error-${i}`, name: `Error Handler ${i}`, type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1, position: [400, i * 20 + 10], parameters: {} }); // Connect source to processing node connections['Source'].main[0].push({ node: `Process ${i}`, type: 'main', index: 0 }); // Connect processing node to success and error handlers connections[`Process ${i}`] = { main: [ [{ node: `Success ${i}`, type: 'main', index: 0 }], [{ node: `Error Handler ${i}`, type: 'main', index: 0 }] ] }; } const workflow = { nodes, connections }; const startTime = performance.now(); const result = await validator.validateWorkflow(workflow as any); const endTime = performance.now(); const executionTime = endTime - startTime; // Should validate efficiently despite many parallel paths expect(executionTime).toBeLessThan(8000); // Less than 8 seconds // Should not have errors about incorrect configuration const incorrectConfigErrors = result.errors.filter(e => e.message.includes('Incorrect error output configuration') ); expect(incorrectConfigErrors.length).toBe(0); console.log(`Validated ${nodes.length} nodes with ${parallelPathCount} parallel error paths in ${executionTime.toFixed(2)}ms`); }); it('should handle worst-case scenario with many incorrect configurations efficiently', async () => { // Create a workflow where many nodes have the incorrect error configuration // This tests the performance of the error detection algorithm const nodeCount = 300; const nodes = []; const connections: any = {}; for (let i = 1; i <= nodeCount; i++) { // Main node nodes.push({ id: `main-${i}`, name: `Main ${i}`, type: 'n8n-nodes-base.httpRequest', typeVersion: 1, position: [i * 20, 100], parameters: {} }); // Success handler nodes.push({ id: `success-${i}`, name: `Success ${i}`, type: 'n8n-nodes-base.set', typeVersion: 1, position: [i * 20, 200], parameters: {} }); // Error handler (with error-indicating name) nodes.push({ id: `error-${i}`, name: `Error Handler ${i}`, type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1, position: [i * 20, 300], parameters: {} }); // INCORRECT configuration: both success and error handlers in main[0] connections[`Main ${i}`] = { main: [ [ { node: `Success ${i}`, type: 'main', index: 0 }, { node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong! ] ] }; } const workflow = { nodes, connections }; const startTime = performance.now(); const result = await validator.validateWorkflow(workflow as any); const endTime = performance.now(); const executionTime = endTime - startTime; // Should complete within reasonable time even when generating many errors expect(executionTime).toBeLessThan(15000); // Less than 15 seconds // Should detect ALL incorrect configurations const incorrectConfigErrors = result.errors.filter(e => e.message.includes('Incorrect error output configuration') ); expect(incorrectConfigErrors.length).toBe(nodeCount); // One error per node console.log(`Detected ${incorrectConfigErrors.length} incorrect configurations in ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`); }); }); describe('Memory Usage and Optimization', () => { it('should not leak memory during large workflow validation', async () => { // Get initial memory usage const initialMemory = process.memoryUsage().heapUsed; // Validate multiple large workflows for (let run = 0; run < 5; run++) { const nodeCount = 200; const nodes = []; const connections: any = {}; for (let i = 1; i <= nodeCount; i++) { nodes.push({ id: i.toString(), name: `Node${i}`, type: 'n8n-nodes-base.httpRequest', typeVersion: 1, position: [i * 10, 100], parameters: {}, onError: 'continueErrorOutput' }); if (i > 1) { connections[`Node${i - 1}`] = { main: [ [{ node: `Node${i}`, type: 'main', index: 0 }], [{ node: `Error${i}`, type: 'main', index: 0 }] ] }; nodes.push({ id: `error-${i}`, name: `Error${i}`, type: 'n8n-nodes-base.set', typeVersion: 1, position: [i * 10, 200], parameters: {} }); } } const workflow = { nodes, connections }; await validator.validateWorkflow(workflow as any); // Force garbage collection if available if (global.gc) { global.gc(); } } const finalMemory = process.memoryUsage().heapUsed; const memoryIncrease = finalMemory - initialMemory; const memoryIncreaseMB = memoryIncrease / (1024 * 1024); // Memory increase should be reasonable (less than 50MB) expect(memoryIncreaseMB).toBeLessThan(50); console.log(`Memory increase after 5 large workflow validations: ${memoryIncreaseMB.toFixed(2)}MB`); }); it('should handle concurrent validation requests efficiently', async () => { // Create multiple validation requests that run concurrently const concurrentRequests = 10; const workflows = []; // Prepare workflows for (let r = 0; r < concurrentRequests; r++) { const nodeCount = 50; const nodes = []; const connections: any = {}; for (let i = 1; i <= nodeCount; i++) { nodes.push({ id: `${r}-${i}`, name: `R${r}Node${i}`, type: i % 2 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set', typeVersion: 1, position: [i * 20, r * 100], parameters: {}, ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {}) }); if (i > 1) { const hasError = i % 3 === 0; const isIncorrect = i % 6 === 0; if (hasError && isIncorrect) { // Incorrect configuration connections[`R${r}Node${i - 1}`] = { main: [ [ { node: `R${r}Node${i}`, type: 'main', index: 0 }, { node: `R${r}Error${i}`, type: 'main', index: 0 } // Wrong! ] ] }; nodes.push({ id: `${r}-error-${i}`, name: `R${r}Error${i}`, type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1, position: [i * 20, r * 100 + 50], parameters: {} }); } else if (hasError) { // Correct configuration connections[`R${r}Node${i - 1}`] = { main: [ [{ node: `R${r}Node${i}`, type: 'main', index: 0 }], [{ node: `R${r}Error${i}`, type: 'main', index: 0 }] ] }; nodes.push({ id: `${r}-error-${i}`, name: `R${r}Error${i}`, type: 'n8n-nodes-base.set', typeVersion: 1, position: [i * 20, r * 100 + 50], parameters: {} }); } else { // Normal connection connections[`R${r}Node${i - 1}`] = { main: [ [{ node: `R${r}Node${i}`, type: 'main', index: 0 }] ] }; } } } workflows.push({ nodes, connections }); } // Run concurrent validations const startTime = performance.now(); const results = await Promise.all( workflows.map(workflow => validator.validateWorkflow(workflow as any)) ); const endTime = performance.now(); const totalTime = endTime - startTime; // All validations should complete expect(results).toHaveLength(concurrentRequests); // Each result should be valid results.forEach(result => { expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.warnings)).toBe(true); }); // Concurrent execution should be efficient expect(totalTime).toBeLessThan(20000); // Less than 20 seconds total console.log(`Completed ${concurrentRequests} concurrent validations in ${totalTime.toFixed(2)}ms`); }); }); }); ```