This is page 24 of 60. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CI_TEST_INFRASTRUCTURE.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 │ │ ├── skills.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 │ ├── expression-utils.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 │ │ │ ├── expression-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /tests/unit/multi-tenant-integration.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration tests for multi-tenant support across the entire codebase 3 | * 4 | * This test file provides comprehensive coverage for the multi-tenant implementation 5 | * by testing the actual behavior and integration points rather than implementation details. 6 | */ 7 | 8 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 9 | import { InstanceContext, isInstanceContext, validateInstanceContext } from '../../src/types/instance-context'; 10 | 11 | // Mock logger properly 12 | vi.mock('../../src/utils/logger', () => ({ 13 | Logger: vi.fn().mockImplementation(() => ({ 14 | debug: vi.fn(), 15 | info: vi.fn(), 16 | warn: vi.fn(), 17 | error: vi.fn() 18 | })), 19 | logger: { 20 | debug: vi.fn(), 21 | info: vi.fn(), 22 | warn: vi.fn(), 23 | error: vi.fn() 24 | } 25 | })); 26 | 27 | describe('Multi-Tenant Support Integration', () => { 28 | let originalEnv: NodeJS.ProcessEnv; 29 | 30 | beforeEach(() => { 31 | originalEnv = { ...process.env }; 32 | vi.clearAllMocks(); 33 | }); 34 | 35 | afterEach(() => { 36 | process.env = originalEnv; 37 | }); 38 | 39 | describe('InstanceContext Validation', () => { 40 | describe('Real-world URL patterns', () => { 41 | const validUrls = [ 42 | 'https://app.n8n.cloud', 43 | 'https://tenant1.n8n.cloud', 44 | 'https://my-company.n8n.cloud', 45 | 'https://n8n.example.com', 46 | 'https://automation.company.com', 47 | 'http://localhost:5678', 48 | 'https://localhost:8443', 49 | 'http://127.0.0.1:5678', 50 | 'https://192.168.1.100:8080', 51 | 'https://10.0.0.1:3000', 52 | 'http://n8n.internal.company.com', 53 | 'https://workflow.enterprise.local' 54 | ]; 55 | 56 | validUrls.forEach(url => { 57 | it(`should accept realistic n8n URL: ${url}`, () => { 58 | const context: InstanceContext = { 59 | n8nApiUrl: url, 60 | n8nApiKey: 'valid-api-key-123' 61 | }; 62 | 63 | expect(isInstanceContext(context)).toBe(true); 64 | 65 | const validation = validateInstanceContext(context); 66 | expect(validation.valid).toBe(true); 67 | expect(validation.errors).toBeUndefined(); 68 | }); 69 | }); 70 | }); 71 | 72 | describe('Security validation', () => { 73 | const maliciousUrls = [ 74 | 'javascript:alert("xss")', 75 | 'vbscript:msgbox("xss")', 76 | 'data:text/html,<script>alert("xss")</script>', 77 | 'file:///etc/passwd', 78 | 'ldap://attacker.com/cn=admin', 79 | 'ftp://malicious.com' 80 | ]; 81 | 82 | maliciousUrls.forEach(url => { 83 | it(`should reject potentially malicious URL: ${url}`, () => { 84 | const context: InstanceContext = { 85 | n8nApiUrl: url, 86 | n8nApiKey: 'valid-key' 87 | }; 88 | 89 | expect(isInstanceContext(context)).toBe(false); 90 | 91 | const validation = validateInstanceContext(context); 92 | expect(validation.valid).toBe(false); 93 | expect(validation.errors).toBeDefined(); 94 | }); 95 | }); 96 | }); 97 | 98 | describe('API key validation', () => { 99 | const invalidApiKeys = [ 100 | '', 101 | 'placeholder', 102 | 'YOUR_API_KEY', 103 | 'example', 104 | 'your_api_key_here' 105 | ]; 106 | 107 | invalidApiKeys.forEach(key => { 108 | it(`should reject invalid API key: "${key}"`, () => { 109 | const context: InstanceContext = { 110 | n8nApiUrl: 'https://valid.n8n.cloud', 111 | n8nApiKey: key 112 | }; 113 | 114 | if (key === '') { 115 | // Empty string validation 116 | const validation = validateInstanceContext(context); 117 | expect(validation.valid).toBe(false); 118 | expect(validation.errors?.[0]).toContain('empty string'); 119 | } else { 120 | // Placeholder validation 121 | expect(isInstanceContext(context)).toBe(false); 122 | } 123 | }); 124 | }); 125 | 126 | it('should accept valid API keys', () => { 127 | const validKeys = [ 128 | 'sk_live_AbCdEf123456789', 129 | 'api-key-12345-abcdef', 130 | 'n8n_api_key_production_v1_xyz', 131 | 'Bearer-token-abc123', 132 | 'jwt.eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9' 133 | ]; 134 | 135 | validKeys.forEach(key => { 136 | const context: InstanceContext = { 137 | n8nApiUrl: 'https://valid.n8n.cloud', 138 | n8nApiKey: key 139 | }; 140 | 141 | expect(isInstanceContext(context)).toBe(true); 142 | const validation = validateInstanceContext(context); 143 | expect(validation.valid).toBe(true); 144 | }); 145 | }); 146 | }); 147 | 148 | describe('Edge cases and error handling', () => { 149 | it('should handle partial instance context', () => { 150 | const partialContext: InstanceContext = { 151 | n8nApiUrl: 'https://tenant1.n8n.cloud' 152 | // n8nApiKey intentionally missing 153 | }; 154 | 155 | expect(isInstanceContext(partialContext)).toBe(true); 156 | const validation = validateInstanceContext(partialContext); 157 | expect(validation.valid).toBe(true); 158 | }); 159 | 160 | it('should handle completely empty context', () => { 161 | const emptyContext: InstanceContext = {}; 162 | 163 | expect(isInstanceContext(emptyContext)).toBe(true); 164 | const validation = validateInstanceContext(emptyContext); 165 | expect(validation.valid).toBe(true); 166 | }); 167 | 168 | it('should handle numerical values gracefully', () => { 169 | const contextWithNumbers: InstanceContext = { 170 | n8nApiUrl: 'https://tenant1.n8n.cloud', 171 | n8nApiKey: 'valid-key', 172 | n8nApiTimeout: 30000, 173 | n8nApiMaxRetries: 3 174 | }; 175 | 176 | expect(isInstanceContext(contextWithNumbers)).toBe(true); 177 | const validation = validateInstanceContext(contextWithNumbers); 178 | expect(validation.valid).toBe(true); 179 | }); 180 | 181 | it('should reject invalid numerical values', () => { 182 | const invalidTimeout: InstanceContext = { 183 | n8nApiUrl: 'https://tenant1.n8n.cloud', 184 | n8nApiKey: 'valid-key', 185 | n8nApiTimeout: -1 186 | }; 187 | 188 | expect(isInstanceContext(invalidTimeout)).toBe(false); 189 | const validation = validateInstanceContext(invalidTimeout); 190 | expect(validation.valid).toBe(false); 191 | expect(validation.errors?.[0]).toContain('Must be positive'); 192 | }); 193 | 194 | it('should reject invalid retry values', () => { 195 | const invalidRetries: InstanceContext = { 196 | n8nApiUrl: 'https://tenant1.n8n.cloud', 197 | n8nApiKey: 'valid-key', 198 | n8nApiMaxRetries: -5 199 | }; 200 | 201 | expect(isInstanceContext(invalidRetries)).toBe(false); 202 | const validation = validateInstanceContext(invalidRetries); 203 | expect(validation.valid).toBe(false); 204 | expect(validation.errors?.[0]).toContain('Must be non-negative'); 205 | }); 206 | }); 207 | }); 208 | 209 | describe('Environment Variable Handling', () => { 210 | it('should handle ENABLE_MULTI_TENANT flag correctly', () => { 211 | // Test various flag values 212 | const flagValues = [ 213 | { value: 'true', expected: true }, 214 | { value: 'false', expected: false }, 215 | { value: 'TRUE', expected: false }, // Case sensitive 216 | { value: 'yes', expected: false }, 217 | { value: '1', expected: false }, 218 | { value: '', expected: false }, 219 | { value: undefined, expected: false } 220 | ]; 221 | 222 | flagValues.forEach(({ value, expected }) => { 223 | if (value === undefined) { 224 | delete process.env.ENABLE_MULTI_TENANT; 225 | } else { 226 | process.env.ENABLE_MULTI_TENANT = value; 227 | } 228 | 229 | const isEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; 230 | expect(isEnabled).toBe(expected); 231 | }); 232 | }); 233 | 234 | it('should handle N8N_API_URL and N8N_API_KEY environment variables', () => { 235 | // Test backward compatibility 236 | process.env.N8N_API_URL = 'https://env.n8n.cloud'; 237 | process.env.N8N_API_KEY = 'env-api-key'; 238 | 239 | const hasEnvConfig = !!(process.env.N8N_API_URL || process.env.N8N_API_KEY); 240 | expect(hasEnvConfig).toBe(true); 241 | 242 | // Test when not set 243 | delete process.env.N8N_API_URL; 244 | delete process.env.N8N_API_KEY; 245 | 246 | const hasNoEnvConfig = !!(process.env.N8N_API_URL || process.env.N8N_API_KEY); 247 | expect(hasNoEnvConfig).toBe(false); 248 | }); 249 | }); 250 | 251 | describe('Header Processing Simulation', () => { 252 | it('should process multi-tenant headers correctly', () => { 253 | // Simulate Express request headers 254 | const mockHeaders = { 255 | 'x-n8n-url': 'https://tenant1.n8n.cloud', 256 | 'x-n8n-key': 'tenant1-api-key', 257 | 'x-instance-id': 'tenant1-instance', 258 | 'x-session-id': 'tenant1-session-123' 259 | }; 260 | 261 | // Simulate header extraction 262 | const extractedContext: InstanceContext = { 263 | n8nApiUrl: mockHeaders['x-n8n-url'], 264 | n8nApiKey: mockHeaders['x-n8n-key'], 265 | instanceId: mockHeaders['x-instance-id'], 266 | sessionId: mockHeaders['x-session-id'] 267 | }; 268 | 269 | expect(isInstanceContext(extractedContext)).toBe(true); 270 | const validation = validateInstanceContext(extractedContext); 271 | expect(validation.valid).toBe(true); 272 | }); 273 | 274 | it('should handle missing headers gracefully', () => { 275 | const mockHeaders: any = { 276 | 'authorization': 'Bearer token', 277 | 'content-type': 'application/json' 278 | // No x-n8n-* headers 279 | }; 280 | 281 | const extractedContext = { 282 | n8nApiUrl: mockHeaders['x-n8n-url'], // undefined 283 | n8nApiKey: mockHeaders['x-n8n-key'] // undefined 284 | }; 285 | 286 | // When no relevant headers exist, context should be undefined 287 | const shouldCreateContext = !!(extractedContext.n8nApiUrl || extractedContext.n8nApiKey); 288 | expect(shouldCreateContext).toBe(false); 289 | }); 290 | 291 | it('should handle malformed headers', () => { 292 | const mockHeaders = { 293 | 'x-n8n-url': 'not-a-url', 294 | 'x-n8n-key': 'placeholder' 295 | }; 296 | 297 | const extractedContext: InstanceContext = { 298 | n8nApiUrl: mockHeaders['x-n8n-url'], 299 | n8nApiKey: mockHeaders['x-n8n-key'] 300 | }; 301 | 302 | expect(isInstanceContext(extractedContext)).toBe(false); 303 | const validation = validateInstanceContext(extractedContext); 304 | expect(validation.valid).toBe(false); 305 | }); 306 | }); 307 | 308 | describe('Configuration Priority Logic', () => { 309 | it('should implement correct priority logic for tool inclusion', () => { 310 | // Test the shouldIncludeManagementTools logic 311 | const scenarios = [ 312 | { 313 | name: 'env config only', 314 | envUrl: 'https://env.example.com', 315 | envKey: 'env-key', 316 | instanceContext: undefined, 317 | multiTenant: false, 318 | expected: true 319 | }, 320 | { 321 | name: 'instance config only', 322 | envUrl: undefined, 323 | envKey: undefined, 324 | instanceContext: { n8nApiUrl: 'https://tenant.example.com', n8nApiKey: 'tenant-key' }, 325 | multiTenant: false, 326 | expected: true 327 | }, 328 | { 329 | name: 'multi-tenant flag only', 330 | envUrl: undefined, 331 | envKey: undefined, 332 | instanceContext: undefined, 333 | multiTenant: true, 334 | expected: true 335 | }, 336 | { 337 | name: 'no configuration', 338 | envUrl: undefined, 339 | envKey: undefined, 340 | instanceContext: undefined, 341 | multiTenant: false, 342 | expected: false 343 | } 344 | ]; 345 | 346 | scenarios.forEach(({ name, envUrl, envKey, instanceContext, multiTenant, expected }) => { 347 | // Setup environment 348 | if (envUrl) process.env.N8N_API_URL = envUrl; 349 | else delete process.env.N8N_API_URL; 350 | 351 | if (envKey) process.env.N8N_API_KEY = envKey; 352 | else delete process.env.N8N_API_KEY; 353 | 354 | if (multiTenant) process.env.ENABLE_MULTI_TENANT = 'true'; 355 | else delete process.env.ENABLE_MULTI_TENANT; 356 | 357 | // Test logic 358 | const hasEnvConfig = !!(process.env.N8N_API_URL || process.env.N8N_API_KEY); 359 | const hasInstanceConfig = !!(instanceContext?.n8nApiUrl || instanceContext?.n8nApiKey); 360 | const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; 361 | 362 | const shouldIncludeManagementTools = hasEnvConfig || hasInstanceConfig || isMultiTenantEnabled; 363 | 364 | expect(shouldIncludeManagementTools).toBe(expected); 365 | }); 366 | }); 367 | }); 368 | 369 | describe('Session Management Concepts', () => { 370 | it('should generate consistent identifiers for same configuration', () => { 371 | const config1 = { 372 | n8nApiUrl: 'https://tenant1.n8n.cloud', 373 | n8nApiKey: 'api-key-123' 374 | }; 375 | 376 | const config2 = { 377 | n8nApiUrl: 'https://tenant1.n8n.cloud', 378 | n8nApiKey: 'api-key-123' 379 | }; 380 | 381 | // Same configuration should produce same hash 382 | const hash1 = JSON.stringify(config1); 383 | const hash2 = JSON.stringify(config2); 384 | expect(hash1).toBe(hash2); 385 | }); 386 | 387 | it('should generate different identifiers for different configurations', () => { 388 | const config1 = { 389 | n8nApiUrl: 'https://tenant1.n8n.cloud', 390 | n8nApiKey: 'api-key-123' 391 | }; 392 | 393 | const config2 = { 394 | n8nApiUrl: 'https://tenant2.n8n.cloud', 395 | n8nApiKey: 'different-api-key' 396 | }; 397 | 398 | // Different configuration should produce different hash 399 | const hash1 = JSON.stringify(config1); 400 | const hash2 = JSON.stringify(config2); 401 | expect(hash1).not.toBe(hash2); 402 | }); 403 | 404 | it('should handle session isolation concepts', () => { 405 | const sessions = new Map(); 406 | 407 | // Simulate creating sessions for different tenants 408 | const tenant1Context = { 409 | n8nApiUrl: 'https://tenant1.n8n.cloud', 410 | n8nApiKey: 'tenant1-key', 411 | instanceId: 'tenant1' 412 | }; 413 | 414 | const tenant2Context = { 415 | n8nApiUrl: 'https://tenant2.n8n.cloud', 416 | n8nApiKey: 'tenant2-key', 417 | instanceId: 'tenant2' 418 | }; 419 | 420 | sessions.set('session-1', { context: tenant1Context, lastAccess: new Date() }); 421 | sessions.set('session-2', { context: tenant2Context, lastAccess: new Date() }); 422 | 423 | // Verify isolation 424 | expect(sessions.get('session-1').context.instanceId).toBe('tenant1'); 425 | expect(sessions.get('session-2').context.instanceId).toBe('tenant2'); 426 | expect(sessions.size).toBe(2); 427 | }); 428 | }); 429 | 430 | describe('Error Scenarios and Recovery', () => { 431 | it('should handle validation errors gracefully', () => { 432 | const invalidContext: InstanceContext = { 433 | n8nApiUrl: '', // Empty URL 434 | n8nApiKey: '', // Empty key 435 | n8nApiTimeout: -1, // Invalid timeout 436 | n8nApiMaxRetries: -1 // Invalid retries 437 | }; 438 | 439 | // Should not throw 440 | expect(() => isInstanceContext(invalidContext)).not.toThrow(); 441 | expect(() => validateInstanceContext(invalidContext)).not.toThrow(); 442 | 443 | const validation = validateInstanceContext(invalidContext); 444 | expect(validation.valid).toBe(false); 445 | expect(validation.errors?.length).toBeGreaterThan(0); 446 | 447 | // Each error should be descriptive 448 | validation.errors?.forEach(error => { 449 | expect(error).toContain('Invalid'); 450 | expect(typeof error).toBe('string'); 451 | expect(error.length).toBeGreaterThan(10); 452 | }); 453 | }); 454 | 455 | it('should provide specific error messages', () => { 456 | const testCases = [ 457 | { 458 | context: { n8nApiUrl: '', n8nApiKey: 'valid' }, 459 | expectedError: 'empty string' 460 | }, 461 | { 462 | context: { n8nApiUrl: 'https://example.com', n8nApiKey: 'placeholder' }, 463 | expectedError: 'placeholder' 464 | }, 465 | { 466 | context: { n8nApiUrl: 'https://example.com', n8nApiKey: 'valid', n8nApiTimeout: -1 }, 467 | expectedError: 'Must be positive' 468 | }, 469 | { 470 | context: { n8nApiUrl: 'https://example.com', n8nApiKey: 'valid', n8nApiMaxRetries: -1 }, 471 | expectedError: 'Must be non-negative' 472 | } 473 | ]; 474 | 475 | testCases.forEach(({ context, expectedError }) => { 476 | const validation = validateInstanceContext(context); 477 | expect(validation.valid).toBe(false); 478 | expect(validation.errors?.some(err => err.includes(expectedError))).toBe(true); 479 | }); 480 | }); 481 | }); 482 | }); ``` -------------------------------------------------------------------------------- /docs/workflow-diff-examples.md: -------------------------------------------------------------------------------- ```markdown 1 | # Workflow Diff Examples 2 | 3 | This guide demonstrates how to use the `n8n_update_partial_workflow` tool for efficient workflow editing. 4 | 5 | ## Overview 6 | 7 | The `n8n_update_partial_workflow` tool allows you to make targeted changes to workflows without sending the entire workflow JSON. This results in: 8 | - 80-90% reduction in token usage 9 | - More precise edits 10 | - Clearer intent 11 | - Reduced risk of accidentally modifying unrelated parts 12 | 13 | ## Basic Usage 14 | 15 | ```json 16 | { 17 | "id": "workflow-id-here", 18 | "operations": [ 19 | { 20 | "type": "operation-type", 21 | "...operation-specific-fields..." 22 | } 23 | ] 24 | } 25 | ``` 26 | 27 | ## Operation Types 28 | 29 | ### 1. Node Operations 30 | 31 | #### Add Node 32 | ```json 33 | { 34 | "type": "addNode", 35 | "description": "Add HTTP Request node to fetch data", 36 | "node": { 37 | "name": "Fetch User Data", 38 | "type": "n8n-nodes-base.httpRequest", 39 | "position": [600, 300], 40 | "parameters": { 41 | "url": "https://api.example.com/users", 42 | "method": "GET", 43 | "authentication": "none" 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | #### Remove Node 50 | ```json 51 | { 52 | "type": "removeNode", 53 | "nodeName": "Old Node Name", 54 | "description": "Remove deprecated node" 55 | } 56 | ``` 57 | 58 | #### Update Node 59 | ```json 60 | { 61 | "type": "updateNode", 62 | "nodeName": "HTTP Request", 63 | "changes": { 64 | "parameters.url": "https://new-api.example.com/v2/users", 65 | "parameters.headers.parameters": [ 66 | { 67 | "name": "Authorization", 68 | "value": "Bearer {{$credentials.apiKey}}" 69 | } 70 | ] 71 | }, 72 | "description": "Update API endpoint to v2" 73 | } 74 | ``` 75 | 76 | #### Move Node 77 | ```json 78 | { 79 | "type": "moveNode", 80 | "nodeName": "Set Variable", 81 | "position": [800, 400], 82 | "description": "Reposition for better layout" 83 | } 84 | ``` 85 | 86 | #### Enable/Disable Node 87 | ```json 88 | { 89 | "type": "disableNode", 90 | "nodeName": "Debug Node", 91 | "description": "Disable debug output for production" 92 | } 93 | ``` 94 | 95 | ### 2. Connection Operations 96 | 97 | #### Add Connection 98 | ```json 99 | { 100 | "type": "addConnection", 101 | "source": "Webhook", 102 | "target": "Process Data", 103 | "sourceOutput": "main", 104 | "targetInput": "main", 105 | "description": "Connect webhook to processor" 106 | } 107 | ``` 108 | 109 | #### Remove Connection 110 | ```json 111 | { 112 | "type": "removeConnection", 113 | "source": "Old Source", 114 | "target": "Old Target", 115 | "description": "Remove unused connection" 116 | } 117 | ``` 118 | 119 | #### Rewire Connection 120 | ```json 121 | { 122 | "type": "rewireConnection", 123 | "source": "Webhook", 124 | "from": "Old Handler", 125 | "to": "New Handler", 126 | "description": "Rewire connection to new handler" 127 | } 128 | ``` 129 | 130 | #### Smart Parameters for IF Nodes 131 | ```json 132 | { 133 | "type": "addConnection", 134 | "source": "IF", 135 | "target": "Success Handler", 136 | "branch": "true", // Semantic parameter instead of sourceIndex 137 | "description": "Route true branch to success handler" 138 | } 139 | ``` 140 | 141 | ```json 142 | { 143 | "type": "addConnection", 144 | "source": "IF", 145 | "target": "Error Handler", 146 | "branch": "false", // Routes to false branch (sourceIndex=1) 147 | "description": "Route false branch to error handler" 148 | } 149 | ``` 150 | 151 | #### Smart Parameters for Switch Nodes 152 | ```json 153 | { 154 | "type": "addConnection", 155 | "source": "Switch", 156 | "target": "Handler A", 157 | "case": 0, // First output 158 | "description": "Route case 0 to Handler A" 159 | } 160 | ``` 161 | 162 | ### 3. Workflow Metadata Operations 163 | 164 | #### Update Workflow Name 165 | ```json 166 | { 167 | "type": "updateName", 168 | "name": "Production User Sync v2", 169 | "description": "Update workflow name for versioning" 170 | } 171 | ``` 172 | 173 | #### Update Settings 174 | ```json 175 | { 176 | "type": "updateSettings", 177 | "settings": { 178 | "executionTimeout": 300, 179 | "saveDataErrorExecution": "all", 180 | "timezone": "America/New_York" 181 | }, 182 | "description": "Configure production settings" 183 | } 184 | ``` 185 | 186 | #### Manage Tags 187 | ```json 188 | { 189 | "type": "addTag", 190 | "tag": "production", 191 | "description": "Mark as production workflow" 192 | } 193 | ``` 194 | 195 | ## Complete Examples 196 | 197 | ### Example 1: Add Slack Notification to Workflow 198 | ```json 199 | { 200 | "id": "workflow-123", 201 | "operations": [ 202 | { 203 | "type": "addNode", 204 | "node": { 205 | "name": "Send Slack Alert", 206 | "type": "n8n-nodes-base.slack", 207 | "position": [1000, 300], 208 | "parameters": { 209 | "resource": "message", 210 | "operation": "post", 211 | "channel": "#alerts", 212 | "text": "Workflow completed successfully!" 213 | } 214 | } 215 | }, 216 | { 217 | "type": "addConnection", 218 | "source": "Process Data", 219 | "target": "Send Slack Alert" 220 | } 221 | ] 222 | } 223 | ``` 224 | 225 | ### Example 2: Update Multiple Webhook Paths 226 | ```json 227 | { 228 | "id": "workflow-456", 229 | "operations": [ 230 | { 231 | "type": "updateNode", 232 | "nodeName": "Webhook 1", 233 | "changes": { 234 | "parameters.path": "v2/webhook1" 235 | } 236 | }, 237 | { 238 | "type": "updateNode", 239 | "nodeName": "Webhook 2", 240 | "changes": { 241 | "parameters.path": "v2/webhook2" 242 | } 243 | }, 244 | { 245 | "type": "updateName", 246 | "name": "API v2 Webhooks" 247 | } 248 | ] 249 | } 250 | ``` 251 | 252 | ### Example 3: Refactor Workflow Structure 253 | ```json 254 | { 255 | "id": "workflow-789", 256 | "operations": [ 257 | { 258 | "type": "removeNode", 259 | "nodeName": "Legacy Processor" 260 | }, 261 | { 262 | "type": "addNode", 263 | "node": { 264 | "name": "Modern Processor", 265 | "type": "n8n-nodes-base.code", 266 | "position": [600, 300], 267 | "parameters": { 268 | "mode": "runOnceForEachItem", 269 | "jsCode": "// Process items\nreturn item;" 270 | } 271 | } 272 | }, 273 | { 274 | "type": "addConnection", 275 | "source": "HTTP Request", 276 | "target": "Modern Processor" 277 | }, 278 | { 279 | "type": "addConnection", 280 | "source": "Modern Processor", 281 | "target": "Save to Database" 282 | } 283 | ] 284 | } 285 | ``` 286 | 287 | ### Example 4: Add Error Handling 288 | ```json 289 | { 290 | "id": "workflow-999", 291 | "operations": [ 292 | { 293 | "type": "addNode", 294 | "node": { 295 | "name": "Error Handler", 296 | "type": "n8n-nodes-base.errorTrigger", 297 | "position": [200, 500] 298 | } 299 | }, 300 | { 301 | "type": "addNode", 302 | "node": { 303 | "name": "Send Error Email", 304 | "type": "n8n-nodes-base.emailSend", 305 | "position": [400, 500], 306 | "parameters": { 307 | "toEmail": "[email protected]", 308 | "subject": "Workflow Error: {{$node['Error Handler'].json.error.message}}", 309 | "text": "Error details: {{$json}}" 310 | } 311 | } 312 | }, 313 | { 314 | "type": "addConnection", 315 | "source": "Error Handler", 316 | "target": "Send Error Email" 317 | }, 318 | { 319 | "type": "updateSettings", 320 | "settings": { 321 | "errorWorkflow": "workflow-999" 322 | } 323 | } 324 | ] 325 | } 326 | ``` 327 | 328 | ### Example 5: Large Batch Workflow Refactoring 329 | Demonstrates handling many operations in a single request - no longer limited to 5 operations! 330 | 331 | ```json 332 | { 333 | "id": "workflow-batch", 334 | "operations": [ 335 | // Add 10 processing nodes 336 | { 337 | "type": "addNode", 338 | "node": { 339 | "name": "Filter Active Users", 340 | "type": "n8n-nodes-base.filter", 341 | "position": [400, 200], 342 | "parameters": { "conditions": { "boolean": [{ "value1": "={{$json.active}}", "value2": true }] } } 343 | } 344 | }, 345 | { 346 | "type": "addNode", 347 | "node": { 348 | "name": "Transform User Data", 349 | "type": "n8n-nodes-base.set", 350 | "position": [600, 200], 351 | "parameters": { "values": { "string": [{ "name": "formatted_name", "value": "={{$json.firstName}} {{$json.lastName}}" }] } } 352 | } 353 | }, 354 | { 355 | "type": "addNode", 356 | "node": { 357 | "name": "Validate Email", 358 | "type": "n8n-nodes-base.if", 359 | "position": [800, 200], 360 | "parameters": { "conditions": { "string": [{ "value1": "={{$json.email}}", "operation": "contains", "value2": "@" }] } } 361 | } 362 | }, 363 | { 364 | "type": "addNode", 365 | "node": { 366 | "name": "Enrich with API", 367 | "type": "n8n-nodes-base.httpRequest", 368 | "position": [1000, 150], 369 | "parameters": { "url": "https://api.example.com/enrich", "method": "POST" } 370 | } 371 | }, 372 | { 373 | "type": "addNode", 374 | "node": { 375 | "name": "Log Invalid Emails", 376 | "type": "n8n-nodes-base.code", 377 | "position": [1000, 350], 378 | "parameters": { "jsCode": "console.log('Invalid email:', $json.email);\nreturn $json;" } 379 | } 380 | }, 381 | { 382 | "type": "addNode", 383 | "node": { 384 | "name": "Merge Results", 385 | "type": "n8n-nodes-base.merge", 386 | "position": [1200, 250] 387 | } 388 | }, 389 | { 390 | "type": "addNode", 391 | "node": { 392 | "name": "Deduplicate", 393 | "type": "n8n-nodes-base.removeDuplicates", 394 | "position": [1400, 250], 395 | "parameters": { "propertyName": "id" } 396 | } 397 | }, 398 | { 399 | "type": "addNode", 400 | "node": { 401 | "name": "Sort by Date", 402 | "type": "n8n-nodes-base.sort", 403 | "position": [1600, 250], 404 | "parameters": { "sortFieldsUi": { "sortField": [{ "fieldName": "created_at", "order": "descending" }] } } 405 | } 406 | }, 407 | { 408 | "type": "addNode", 409 | "node": { 410 | "name": "Batch for DB", 411 | "type": "n8n-nodes-base.splitInBatches", 412 | "position": [1800, 250], 413 | "parameters": { "batchSize": 100 } 414 | } 415 | }, 416 | { 417 | "type": "addNode", 418 | "node": { 419 | "name": "Save to Database", 420 | "type": "n8n-nodes-base.postgres", 421 | "position": [2000, 250], 422 | "parameters": { "operation": "insert", "table": "processed_users" } 423 | } 424 | }, 425 | // Connect all the nodes 426 | { 427 | "type": "addConnection", 428 | "source": "Get Users", 429 | "target": "Filter Active Users" 430 | }, 431 | { 432 | "type": "addConnection", 433 | "source": "Filter Active Users", 434 | "target": "Transform User Data" 435 | }, 436 | { 437 | "type": "addConnection", 438 | "source": "Transform User Data", 439 | "target": "Validate Email" 440 | }, 441 | { 442 | "type": "addConnection", 443 | "source": "Validate Email", 444 | "sourceOutput": "true", 445 | "target": "Enrich with API" 446 | }, 447 | { 448 | "type": "addConnection", 449 | "source": "Validate Email", 450 | "sourceOutput": "false", 451 | "target": "Log Invalid Emails" 452 | }, 453 | { 454 | "type": "addConnection", 455 | "source": "Enrich with API", 456 | "target": "Merge Results" 457 | }, 458 | { 459 | "type": "addConnection", 460 | "source": "Log Invalid Emails", 461 | "target": "Merge Results", 462 | "targetInput": "input2" 463 | }, 464 | { 465 | "type": "addConnection", 466 | "source": "Merge Results", 467 | "target": "Deduplicate" 468 | }, 469 | { 470 | "type": "addConnection", 471 | "source": "Deduplicate", 472 | "target": "Sort by Date" 473 | }, 474 | { 475 | "type": "addConnection", 476 | "source": "Sort by Date", 477 | "target": "Batch for DB" 478 | }, 479 | { 480 | "type": "addConnection", 481 | "source": "Batch for DB", 482 | "target": "Save to Database" 483 | }, 484 | // Update workflow metadata 485 | { 486 | "type": "updateName", 487 | "name": "User Processing Pipeline v2" 488 | }, 489 | { 490 | "type": "updateSettings", 491 | "settings": { 492 | "executionOrder": "v1", 493 | "timezone": "UTC", 494 | "saveDataSuccessExecution": "all" 495 | } 496 | }, 497 | { 498 | "type": "addTag", 499 | "tag": "production" 500 | }, 501 | { 502 | "type": "addTag", 503 | "tag": "user-processing" 504 | }, 505 | { 506 | "type": "addTag", 507 | "tag": "v2" 508 | } 509 | ] 510 | } 511 | ``` 512 | 513 | This example shows 26 operations in a single request, creating a complete data processing pipeline with proper error handling, validation, and batch processing. 514 | 515 | ## Best Practices 516 | 517 | 1. **Use Descriptive Names**: Always provide clear node names and descriptions for operations 518 | 2. **Batch Related Changes**: Group related operations in a single request 519 | 3. **Validate First**: Use `validateOnly: true` to test your operations before applying 520 | 4. **Reference by Name**: Prefer node names over IDs for better readability 521 | 5. **Small, Focused Changes**: Make targeted edits rather than large structural changes 522 | 523 | ## Common Patterns 524 | 525 | ### Add Processing Step 526 | ```json 527 | { 528 | "operations": [ 529 | { 530 | "type": "removeConnection", 531 | "source": "Source Node", 532 | "target": "Target Node" 533 | }, 534 | { 535 | "type": "addNode", 536 | "node": { 537 | "name": "Process Step", 538 | "type": "n8n-nodes-base.set", 539 | "position": [600, 300], 540 | "parameters": { /* ... */ } 541 | } 542 | }, 543 | { 544 | "type": "addConnection", 545 | "source": "Source Node", 546 | "target": "Process Step" 547 | }, 548 | { 549 | "type": "addConnection", 550 | "source": "Process Step", 551 | "target": "Target Node" 552 | } 553 | ] 554 | } 555 | ``` 556 | 557 | ### Replace Node 558 | ```json 559 | { 560 | "operations": [ 561 | { 562 | "type": "addNode", 563 | "node": { 564 | "name": "New Implementation", 565 | "type": "n8n-nodes-base.httpRequest", 566 | "position": [600, 300], 567 | "parameters": { /* ... */ } 568 | } 569 | }, 570 | { 571 | "type": "removeConnection", 572 | "source": "Previous Node", 573 | "target": "Old Implementation" 574 | }, 575 | { 576 | "type": "removeConnection", 577 | "source": "Old Implementation", 578 | "target": "Next Node" 579 | }, 580 | { 581 | "type": "addConnection", 582 | "source": "Previous Node", 583 | "target": "New Implementation" 584 | }, 585 | { 586 | "type": "addConnection", 587 | "source": "New Implementation", 588 | "target": "Next Node" 589 | }, 590 | { 591 | "type": "removeNode", 592 | "nodeName": "Old Implementation" 593 | } 594 | ] 595 | } 596 | ``` 597 | 598 | ## Error Handling 599 | 600 | The tool validates all operations before applying any changes. Common errors include: 601 | 602 | - **Duplicate node names**: Each node must have a unique name 603 | - **Invalid node types**: Use full package prefixes (e.g., `n8n-nodes-base.webhook`) 604 | - **Missing connections**: Referenced nodes must exist 605 | - **Circular dependencies**: Connections cannot create loops 606 | 607 | Always check the response for validation errors and adjust your operations accordingly. 608 | 609 | ## Transactional Updates 610 | 611 | The diff engine now supports transactional updates using a **two-pass processing** approach: 612 | 613 | ### How It Works 614 | 615 | 1. **No Operation Limit**: Process unlimited operations in a single request 616 | 2. **Two-Pass Processing**: 617 | - **Pass 1**: All node operations (add, remove, update, move, enable, disable) 618 | - **Pass 2**: All other operations (connections, settings, metadata) 619 | 620 | This allows you to add nodes and connect them in the same request: 621 | 622 | ```json 623 | { 624 | "id": "workflow-id", 625 | "operations": [ 626 | // These will be processed in Pass 2 (but work because nodes are added first) 627 | { 628 | "type": "addConnection", 629 | "source": "Webhook", 630 | "target": "Process Data" 631 | }, 632 | { 633 | "type": "addConnection", 634 | "source": "Process Data", 635 | "target": "Send Email" 636 | }, 637 | // These will be processed in Pass 1 638 | { 639 | "type": "addNode", 640 | "node": { 641 | "name": "Process Data", 642 | "type": "n8n-nodes-base.set", 643 | "position": [400, 300], 644 | "parameters": {} 645 | } 646 | }, 647 | { 648 | "type": "addNode", 649 | "node": { 650 | "name": "Send Email", 651 | "type": "n8n-nodes-base.emailSend", 652 | "position": [600, 300], 653 | "parameters": { 654 | "to": "[email protected]" 655 | } 656 | } 657 | } 658 | ] 659 | } 660 | ``` 661 | 662 | ### Benefits 663 | 664 | - **Order Independence**: You don't need to worry about operation order 665 | - **Atomic Updates**: All operations succeed or all fail (unless continueOnError is enabled) 666 | - **Intuitive Usage**: Add complex workflow structures in one call 667 | - **No Hard Limits**: Process unlimited operations efficiently 668 | 669 | ### Example: Complete Workflow Addition 670 | 671 | ```json 672 | { 673 | "id": "workflow-id", 674 | "operations": [ 675 | // Add three nodes 676 | { 677 | "type": "addNode", 678 | "node": { 679 | "name": "Schedule", 680 | "type": "n8n-nodes-base.schedule", 681 | "position": [200, 300], 682 | "parameters": { 683 | "rule": { 684 | "interval": [{ "field": "hours", "intervalValue": 1 }] 685 | } 686 | } 687 | } 688 | }, 689 | { 690 | "type": "addNode", 691 | "node": { 692 | "name": "Get Data", 693 | "type": "n8n-nodes-base.httpRequest", 694 | "position": [400, 300], 695 | "parameters": { 696 | "url": "https://api.example.com/data" 697 | } 698 | } 699 | }, 700 | { 701 | "type": "addNode", 702 | "node": { 703 | "name": "Save to Database", 704 | "type": "n8n-nodes-base.postgres", 705 | "position": [600, 300], 706 | "parameters": { 707 | "operation": "insert" 708 | } 709 | } 710 | }, 711 | // Connect them all 712 | { 713 | "type": "addConnection", 714 | "source": "Schedule", 715 | "target": "Get Data" 716 | }, 717 | { 718 | "type": "addConnection", 719 | "source": "Get Data", 720 | "target": "Save to Database" 721 | } 722 | ] 723 | } 724 | ``` 725 | 726 | All operations will be processed correctly regardless of order! ``` -------------------------------------------------------------------------------- /src/database/database-adapter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { promises as fs } from 'fs'; 2 | import * as fsSync from 'fs'; 3 | import path from 'path'; 4 | import { logger } from '../utils/logger'; 5 | 6 | /** 7 | * Unified database interface that abstracts better-sqlite3 and sql.js 8 | */ 9 | export interface DatabaseAdapter { 10 | prepare(sql: string): PreparedStatement; 11 | exec(sql: string): void; 12 | close(): void; 13 | pragma(key: string, value?: any): any; 14 | readonly inTransaction: boolean; 15 | transaction<T>(fn: () => T): T; 16 | checkFTS5Support(): boolean; 17 | } 18 | 19 | export interface PreparedStatement { 20 | run(...params: any[]): RunResult; 21 | get(...params: any[]): any; 22 | all(...params: any[]): any[]; 23 | iterate(...params: any[]): IterableIterator<any>; 24 | pluck(toggle?: boolean): this; 25 | expand(toggle?: boolean): this; 26 | raw(toggle?: boolean): this; 27 | columns(): ColumnDefinition[]; 28 | bind(...params: any[]): this; 29 | } 30 | 31 | export interface RunResult { 32 | changes: number; 33 | lastInsertRowid: number | bigint; 34 | } 35 | 36 | export interface ColumnDefinition { 37 | name: string; 38 | column: string | null; 39 | table: string | null; 40 | database: string | null; 41 | type: string | null; 42 | } 43 | 44 | /** 45 | * Factory function to create a database adapter 46 | * Tries better-sqlite3 first, falls back to sql.js if needed 47 | */ 48 | export async function createDatabaseAdapter(dbPath: string): Promise<DatabaseAdapter> { 49 | // Log Node.js version information 50 | // Only log in non-stdio mode 51 | if (process.env.MCP_MODE !== 'stdio') { 52 | logger.info(`Node.js version: ${process.version}`); 53 | } 54 | // Only log in non-stdio mode 55 | if (process.env.MCP_MODE !== 'stdio') { 56 | logger.info(`Platform: ${process.platform} ${process.arch}`); 57 | } 58 | 59 | // First, try to use better-sqlite3 60 | try { 61 | if (process.env.MCP_MODE !== 'stdio') { 62 | logger.info('Attempting to use better-sqlite3...'); 63 | } 64 | const adapter = await createBetterSQLiteAdapter(dbPath); 65 | if (process.env.MCP_MODE !== 'stdio') { 66 | logger.info('Successfully initialized better-sqlite3 adapter'); 67 | } 68 | return adapter; 69 | } catch (error) { 70 | const errorMessage = error instanceof Error ? error.message : String(error); 71 | 72 | // Check if it's a version mismatch error 73 | if (errorMessage.includes('NODE_MODULE_VERSION') || errorMessage.includes('was compiled against a different Node.js version')) { 74 | if (process.env.MCP_MODE !== 'stdio') { 75 | logger.warn(`Node.js version mismatch detected. Better-sqlite3 was compiled for a different Node.js version.`); 76 | } 77 | if (process.env.MCP_MODE !== 'stdio') { 78 | logger.warn(`Current Node.js version: ${process.version}`); 79 | } 80 | } 81 | 82 | if (process.env.MCP_MODE !== 'stdio') { 83 | logger.warn('Failed to initialize better-sqlite3, falling back to sql.js', error); 84 | } 85 | 86 | // Fall back to sql.js 87 | try { 88 | const adapter = await createSQLJSAdapter(dbPath); 89 | if (process.env.MCP_MODE !== 'stdio') { 90 | logger.info('Successfully initialized sql.js adapter (pure JavaScript, no native dependencies)'); 91 | } 92 | return adapter; 93 | } catch (sqlJsError) { 94 | if (process.env.MCP_MODE !== 'stdio') { 95 | logger.error('Failed to initialize sql.js adapter', sqlJsError); 96 | } 97 | throw new Error('Failed to initialize any database adapter'); 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * Create better-sqlite3 adapter 104 | */ 105 | async function createBetterSQLiteAdapter(dbPath: string): Promise<DatabaseAdapter> { 106 | try { 107 | const Database = require('better-sqlite3'); 108 | const db = new Database(dbPath); 109 | 110 | return new BetterSQLiteAdapter(db); 111 | } catch (error) { 112 | throw new Error(`Failed to create better-sqlite3 adapter: ${error}`); 113 | } 114 | } 115 | 116 | /** 117 | * Create sql.js adapter with persistence 118 | */ 119 | async function createSQLJSAdapter(dbPath: string): Promise<DatabaseAdapter> { 120 | let initSqlJs; 121 | try { 122 | initSqlJs = require('sql.js'); 123 | } catch (error) { 124 | logger.error('Failed to load sql.js module:', error); 125 | throw new Error('sql.js module not found. This might be an issue with npm package installation.'); 126 | } 127 | 128 | // Initialize sql.js 129 | const SQL = await initSqlJs({ 130 | // This will look for the wasm file in node_modules 131 | locateFile: (file: string) => { 132 | if (file.endsWith('.wasm')) { 133 | // Try multiple paths to find the WASM file 134 | const possiblePaths = [ 135 | // Local development path 136 | path.join(__dirname, '../../node_modules/sql.js/dist/', file), 137 | // When installed as npm package 138 | path.join(__dirname, '../../../sql.js/dist/', file), 139 | // Alternative npm package path 140 | path.join(process.cwd(), 'node_modules/sql.js/dist/', file), 141 | // Try to resolve from require 142 | path.join(path.dirname(require.resolve('sql.js')), '../dist/', file) 143 | ]; 144 | 145 | // Find the first existing path 146 | for (const tryPath of possiblePaths) { 147 | if (fsSync.existsSync(tryPath)) { 148 | if (process.env.MCP_MODE !== 'stdio') { 149 | logger.debug(`Found WASM file at: ${tryPath}`); 150 | } 151 | return tryPath; 152 | } 153 | } 154 | 155 | // If not found, try the last resort - require.resolve 156 | try { 157 | const wasmPath = require.resolve('sql.js/dist/sql-wasm.wasm'); 158 | if (process.env.MCP_MODE !== 'stdio') { 159 | logger.debug(`Found WASM file via require.resolve: ${wasmPath}`); 160 | } 161 | return wasmPath; 162 | } catch (e) { 163 | // Fall back to the default path 164 | logger.warn(`Could not find WASM file, using default path: ${file}`); 165 | return file; 166 | } 167 | } 168 | return file; 169 | } 170 | }); 171 | 172 | // Try to load existing database 173 | let db: any; 174 | try { 175 | const data = await fs.readFile(dbPath); 176 | db = new SQL.Database(new Uint8Array(data)); 177 | logger.info(`Loaded existing database from ${dbPath}`); 178 | } catch (error) { 179 | // Create new database if file doesn't exist 180 | db = new SQL.Database(); 181 | logger.info(`Created new database at ${dbPath}`); 182 | } 183 | 184 | return new SQLJSAdapter(db, dbPath); 185 | } 186 | 187 | /** 188 | * Adapter for better-sqlite3 189 | */ 190 | class BetterSQLiteAdapter implements DatabaseAdapter { 191 | constructor(private db: any) {} 192 | 193 | prepare(sql: string): PreparedStatement { 194 | const stmt = this.db.prepare(sql); 195 | return new BetterSQLiteStatement(stmt); 196 | } 197 | 198 | exec(sql: string): void { 199 | this.db.exec(sql); 200 | } 201 | 202 | close(): void { 203 | this.db.close(); 204 | } 205 | 206 | pragma(key: string, value?: any): any { 207 | return this.db.pragma(key, value); 208 | } 209 | 210 | get inTransaction(): boolean { 211 | return this.db.inTransaction; 212 | } 213 | 214 | transaction<T>(fn: () => T): T { 215 | return this.db.transaction(fn)(); 216 | } 217 | 218 | checkFTS5Support(): boolean { 219 | try { 220 | // Test if FTS5 is available 221 | this.exec("CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);"); 222 | this.exec("DROP TABLE IF EXISTS test_fts5;"); 223 | return true; 224 | } catch (error) { 225 | return false; 226 | } 227 | } 228 | } 229 | 230 | /** 231 | * Adapter for sql.js with persistence 232 | */ 233 | class SQLJSAdapter implements DatabaseAdapter { 234 | private saveTimer: NodeJS.Timeout | null = null; 235 | private saveIntervalMs: number; 236 | private closed = false; // Prevent multiple close() calls 237 | 238 | // Default save interval: 5 seconds (balance between data safety and performance) 239 | // Configurable via SQLJS_SAVE_INTERVAL_MS environment variable 240 | // 241 | // DATA LOSS WINDOW: Up to 5 seconds of database changes may be lost if process 242 | // crashes before scheduleSave() timer fires. This is acceptable because: 243 | // 1. close() calls saveToFile() immediately on graceful shutdown 244 | // 2. Docker/Kubernetes SIGTERM provides 30s for cleanup (more than enough) 245 | // 3. The alternative (100ms interval) caused 2.2GB memory leaks in production 246 | // 4. MCP server is primarily read-heavy (writes are rare) 247 | private static readonly DEFAULT_SAVE_INTERVAL_MS = 5000; 248 | 249 | constructor(private db: any, private dbPath: string) { 250 | // Read save interval from environment or use default 251 | const envInterval = process.env.SQLJS_SAVE_INTERVAL_MS; 252 | this.saveIntervalMs = envInterval ? parseInt(envInterval, 10) : SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS; 253 | 254 | // Validate interval (minimum 100ms, maximum 60000ms = 1 minute) 255 | if (isNaN(this.saveIntervalMs) || this.saveIntervalMs < 100 || this.saveIntervalMs > 60000) { 256 | logger.warn( 257 | `Invalid SQLJS_SAVE_INTERVAL_MS value: ${envInterval} (must be 100-60000ms), ` + 258 | `using default ${SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS}ms` 259 | ); 260 | this.saveIntervalMs = SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS; 261 | } 262 | 263 | logger.debug(`SQLJSAdapter initialized with save interval: ${this.saveIntervalMs}ms`); 264 | 265 | // NOTE: No initial save scheduled here (optimization) 266 | // Database is either: 267 | // 1. Loaded from existing file (already persisted), or 268 | // 2. New database (will be saved on first write operation) 269 | } 270 | 271 | prepare(sql: string): PreparedStatement { 272 | const stmt = this.db.prepare(sql); 273 | // Don't schedule save on prepare - only on actual writes (via SQLJSStatement.run()) 274 | return new SQLJSStatement(stmt, () => this.scheduleSave()); 275 | } 276 | 277 | exec(sql: string): void { 278 | this.db.exec(sql); 279 | this.scheduleSave(); 280 | } 281 | 282 | close(): void { 283 | if (this.closed) { 284 | logger.debug('SQLJSAdapter already closed, skipping'); 285 | return; 286 | } 287 | 288 | this.saveToFile(); 289 | if (this.saveTimer) { 290 | clearTimeout(this.saveTimer); 291 | this.saveTimer = null; 292 | } 293 | this.db.close(); 294 | this.closed = true; 295 | } 296 | 297 | pragma(key: string, value?: any): any { 298 | // sql.js doesn't support pragma in the same way 299 | // We'll handle specific pragmas as needed 300 | if (key === 'journal_mode' && value === 'WAL') { 301 | // WAL mode not supported in sql.js, ignore 302 | return 'memory'; 303 | } 304 | return null; 305 | } 306 | 307 | get inTransaction(): boolean { 308 | // sql.js doesn't expose transaction state 309 | return false; 310 | } 311 | 312 | transaction<T>(fn: () => T): T { 313 | // Simple transaction implementation for sql.js 314 | try { 315 | this.exec('BEGIN'); 316 | const result = fn(); 317 | this.exec('COMMIT'); 318 | return result; 319 | } catch (error) { 320 | this.exec('ROLLBACK'); 321 | throw error; 322 | } 323 | } 324 | 325 | checkFTS5Support(): boolean { 326 | try { 327 | // Test if FTS5 is available 328 | this.exec("CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);"); 329 | this.exec("DROP TABLE IF EXISTS test_fts5;"); 330 | return true; 331 | } catch (error) { 332 | // sql.js doesn't support FTS5 333 | return false; 334 | } 335 | } 336 | 337 | private scheduleSave(): void { 338 | if (this.saveTimer) { 339 | clearTimeout(this.saveTimer); 340 | } 341 | 342 | // Save after configured interval of inactivity (default: 5000ms) 343 | // This debouncing reduces memory churn from frequent buffer allocations 344 | // 345 | // NOTE: Under constant write load, saves may be delayed until writes stop. 346 | // This is acceptable because: 347 | // 1. MCP server is primarily read-heavy (node lookups, searches) 348 | // 2. Writes are rare (only during database rebuilds) 349 | // 3. close() saves immediately on shutdown, flushing any pending changes 350 | this.saveTimer = setTimeout(() => { 351 | this.saveToFile(); 352 | }, this.saveIntervalMs); 353 | } 354 | 355 | private saveToFile(): void { 356 | try { 357 | // Export database to Uint8Array (2-5MB typical) 358 | const data = this.db.export(); 359 | 360 | // Write directly without Buffer.from() copy (saves 50% memory allocation) 361 | // writeFileSync accepts Uint8Array directly, no need for Buffer conversion 362 | fsSync.writeFileSync(this.dbPath, data); 363 | logger.debug(`Database saved to ${this.dbPath}`); 364 | 365 | // Note: 'data' reference is automatically cleared when function exits 366 | // V8 GC will reclaim the Uint8Array once it's no longer referenced 367 | } catch (error) { 368 | logger.error('Failed to save database', error); 369 | } 370 | } 371 | } 372 | 373 | /** 374 | * Statement wrapper for better-sqlite3 375 | */ 376 | class BetterSQLiteStatement implements PreparedStatement { 377 | constructor(private stmt: any) {} 378 | 379 | run(...params: any[]): RunResult { 380 | return this.stmt.run(...params); 381 | } 382 | 383 | get(...params: any[]): any { 384 | return this.stmt.get(...params); 385 | } 386 | 387 | all(...params: any[]): any[] { 388 | return this.stmt.all(...params); 389 | } 390 | 391 | iterate(...params: any[]): IterableIterator<any> { 392 | return this.stmt.iterate(...params); 393 | } 394 | 395 | pluck(toggle?: boolean): this { 396 | this.stmt.pluck(toggle); 397 | return this; 398 | } 399 | 400 | expand(toggle?: boolean): this { 401 | this.stmt.expand(toggle); 402 | return this; 403 | } 404 | 405 | raw(toggle?: boolean): this { 406 | this.stmt.raw(toggle); 407 | return this; 408 | } 409 | 410 | columns(): ColumnDefinition[] { 411 | return this.stmt.columns(); 412 | } 413 | 414 | bind(...params: any[]): this { 415 | this.stmt.bind(...params); 416 | return this; 417 | } 418 | } 419 | 420 | /** 421 | * Statement wrapper for sql.js 422 | */ 423 | class SQLJSStatement implements PreparedStatement { 424 | private boundParams: any = null; 425 | 426 | constructor(private stmt: any, private onModify: () => void) {} 427 | 428 | run(...params: any[]): RunResult { 429 | try { 430 | if (params.length > 0) { 431 | this.bindParams(params); 432 | if (this.boundParams) { 433 | this.stmt.bind(this.boundParams); 434 | } 435 | } 436 | 437 | this.stmt.run(); 438 | this.onModify(); 439 | 440 | // sql.js doesn't provide changes/lastInsertRowid easily 441 | return { 442 | changes: 1, // Assume success means 1 change 443 | lastInsertRowid: 0 444 | }; 445 | } catch (error) { 446 | this.stmt.reset(); 447 | throw error; 448 | } 449 | } 450 | 451 | get(...params: any[]): any { 452 | try { 453 | if (params.length > 0) { 454 | this.bindParams(params); 455 | if (this.boundParams) { 456 | this.stmt.bind(this.boundParams); 457 | } 458 | } 459 | 460 | if (this.stmt.step()) { 461 | const result = this.stmt.getAsObject(); 462 | this.stmt.reset(); 463 | return this.convertIntegerColumns(result); 464 | } 465 | 466 | this.stmt.reset(); 467 | return undefined; 468 | } catch (error) { 469 | this.stmt.reset(); 470 | throw error; 471 | } 472 | } 473 | 474 | all(...params: any[]): any[] { 475 | try { 476 | if (params.length > 0) { 477 | this.bindParams(params); 478 | if (this.boundParams) { 479 | this.stmt.bind(this.boundParams); 480 | } 481 | } 482 | 483 | const results: any[] = []; 484 | while (this.stmt.step()) { 485 | results.push(this.convertIntegerColumns(this.stmt.getAsObject())); 486 | } 487 | 488 | this.stmt.reset(); 489 | return results; 490 | } catch (error) { 491 | this.stmt.reset(); 492 | throw error; 493 | } 494 | } 495 | 496 | iterate(...params: any[]): IterableIterator<any> { 497 | // sql.js doesn't support generators well, return array iterator 498 | return this.all(...params)[Symbol.iterator](); 499 | } 500 | 501 | pluck(toggle?: boolean): this { 502 | // Not directly supported in sql.js 503 | return this; 504 | } 505 | 506 | expand(toggle?: boolean): this { 507 | // Not directly supported in sql.js 508 | return this; 509 | } 510 | 511 | raw(toggle?: boolean): this { 512 | // Not directly supported in sql.js 513 | return this; 514 | } 515 | 516 | columns(): ColumnDefinition[] { 517 | // sql.js has different column info 518 | return []; 519 | } 520 | 521 | bind(...params: any[]): this { 522 | this.bindParams(params); 523 | return this; 524 | } 525 | 526 | private bindParams(params: any[]): void { 527 | if (params.length === 0) { 528 | this.boundParams = null; 529 | return; 530 | } 531 | 532 | if (params.length === 1 && typeof params[0] === 'object' && !Array.isArray(params[0]) && params[0] !== null) { 533 | // Named parameters passed as object 534 | this.boundParams = params[0]; 535 | } else { 536 | // Positional parameters - sql.js uses array for positional 537 | // Filter out undefined values that might cause issues 538 | this.boundParams = params.map(p => p === undefined ? null : p); 539 | } 540 | } 541 | 542 | /** 543 | * Convert SQLite integer columns to JavaScript numbers 544 | * sql.js returns all values as strings, but we need proper types for boolean conversion 545 | */ 546 | private convertIntegerColumns(row: any): any { 547 | if (!row) return row; 548 | 549 | // Known integer columns in the nodes table 550 | const integerColumns = ['is_ai_tool', 'is_trigger', 'is_webhook', 'is_versioned']; 551 | 552 | const converted = { ...row }; 553 | for (const col of integerColumns) { 554 | if (col in converted && typeof converted[col] === 'string') { 555 | converted[col] = parseInt(converted[col], 10); 556 | } 557 | } 558 | 559 | return converted; 560 | } 561 | } ``` -------------------------------------------------------------------------------- /tests/unit/database/template-repository-core.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { TemplateRepository, StoredTemplate } from '../../../src/templates/template-repository'; 3 | import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter'; 4 | import { TemplateWorkflow, TemplateDetail } from '../../../src/templates/template-fetcher'; 5 | 6 | // Mock logger 7 | vi.mock('../../../src/utils/logger', () => ({ 8 | logger: { 9 | info: vi.fn(), 10 | warn: vi.fn(), 11 | error: vi.fn(), 12 | debug: vi.fn() 13 | } 14 | })); 15 | 16 | // Mock template sanitizer 17 | vi.mock('../../../src/utils/template-sanitizer', () => { 18 | class MockTemplateSanitizer { 19 | sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false })); 20 | detectTokens = vi.fn(() => []); 21 | } 22 | 23 | return { 24 | TemplateSanitizer: MockTemplateSanitizer 25 | }; 26 | }); 27 | 28 | // Create mock database adapter 29 | class MockDatabaseAdapter implements DatabaseAdapter { 30 | private statements = new Map<string, MockPreparedStatement>(); 31 | private mockData = new Map<string, any>(); 32 | private _fts5Support = true; 33 | 34 | prepare = vi.fn((sql: string) => { 35 | if (!this.statements.has(sql)) { 36 | this.statements.set(sql, new MockPreparedStatement(sql, this.mockData)); 37 | } 38 | return this.statements.get(sql)!; 39 | }); 40 | 41 | exec = vi.fn(); 42 | close = vi.fn(); 43 | pragma = vi.fn(); 44 | transaction = vi.fn((fn: () => any) => fn()); 45 | checkFTS5Support = vi.fn(() => this._fts5Support); 46 | inTransaction = false; 47 | 48 | // Test helpers 49 | _setFTS5Support(supported: boolean) { 50 | this._fts5Support = supported; 51 | } 52 | 53 | _setMockData(key: string, value: any) { 54 | this.mockData.set(key, value); 55 | } 56 | 57 | _getStatement(sql: string) { 58 | return this.statements.get(sql); 59 | } 60 | } 61 | 62 | class MockPreparedStatement implements PreparedStatement { 63 | run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 })); 64 | get = vi.fn(); 65 | all = vi.fn(() => []); 66 | iterate = vi.fn(); 67 | pluck = vi.fn(() => this); 68 | expand = vi.fn(() => this); 69 | raw = vi.fn(() => this); 70 | columns = vi.fn(() => []); 71 | bind = vi.fn(() => this); 72 | 73 | constructor(private sql: string, private mockData: Map<string, any>) { 74 | // Configure based on SQL patterns 75 | if (sql.includes('SELECT * FROM templates WHERE id = ?')) { 76 | this.get = vi.fn((id: number) => this.mockData.get(`template:${id}`)); 77 | } 78 | 79 | if (sql.includes('SELECT * FROM templates') && sql.includes('LIMIT')) { 80 | this.all = vi.fn(() => this.mockData.get('all_templates') || []); 81 | } 82 | 83 | if (sql.includes('templates_fts')) { 84 | this.all = vi.fn(() => this.mockData.get('fts_results') || []); 85 | } 86 | 87 | if (sql.includes('WHERE name LIKE')) { 88 | this.all = vi.fn(() => this.mockData.get('like_results') || []); 89 | } 90 | 91 | if (sql.includes('COUNT(*) as count')) { 92 | this.get = vi.fn(() => ({ count: this.mockData.get('template_count') || 0 })); 93 | } 94 | 95 | if (sql.includes('AVG(views)')) { 96 | this.get = vi.fn(() => ({ avg: this.mockData.get('avg_views') || 0 })); 97 | } 98 | 99 | if (sql.includes('sqlite_master')) { 100 | this.get = vi.fn(() => this.mockData.get('fts_table_exists') ? { name: 'templates_fts' } : undefined); 101 | } 102 | } 103 | } 104 | 105 | describe('TemplateRepository - Core Functionality', () => { 106 | let repository: TemplateRepository; 107 | let mockAdapter: MockDatabaseAdapter; 108 | 109 | beforeEach(() => { 110 | vi.clearAllMocks(); 111 | mockAdapter = new MockDatabaseAdapter(); 112 | mockAdapter._setMockData('fts_table_exists', false); // Default to creating FTS 113 | repository = new TemplateRepository(mockAdapter); 114 | }); 115 | 116 | describe('FTS5 initialization', () => { 117 | it('should initialize FTS5 when supported', () => { 118 | expect(mockAdapter.checkFTS5Support).toHaveBeenCalled(); 119 | expect(mockAdapter.exec).toHaveBeenCalledWith(expect.stringContaining('CREATE VIRTUAL TABLE')); 120 | }); 121 | 122 | it('should skip FTS5 when not supported', () => { 123 | mockAdapter._setFTS5Support(false); 124 | mockAdapter.exec.mockClear(); 125 | 126 | const newRepo = new TemplateRepository(mockAdapter); 127 | 128 | expect(mockAdapter.exec).not.toHaveBeenCalledWith(expect.stringContaining('CREATE VIRTUAL TABLE')); 129 | }); 130 | }); 131 | 132 | describe('saveTemplate', () => { 133 | it('should save a template with proper JSON serialization', () => { 134 | const workflow: TemplateWorkflow = { 135 | id: 123, 136 | name: 'Test Workflow', 137 | description: 'A test workflow', 138 | user: { 139 | id: 1, 140 | name: 'John Doe', 141 | username: 'johndoe', 142 | verified: true 143 | }, 144 | nodes: [ 145 | { id: 1, name: 'n8n-nodes-base.httpRequest', icon: 'fa:globe' }, 146 | { id: 2, name: 'n8n-nodes-base.slack', icon: 'fa:slack' } 147 | ], 148 | totalViews: 1000, 149 | createdAt: '2024-01-01T00:00:00Z' 150 | }; 151 | 152 | const detail: TemplateDetail = { 153 | id: 123, 154 | name: 'Test Workflow', 155 | description: 'A test workflow', 156 | views: 1000, 157 | createdAt: '2024-01-01T00:00:00Z', 158 | workflow: { 159 | nodes: [ 160 | { type: 'n8n-nodes-base.httpRequest', name: 'HTTP Request', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }, 161 | { type: 'n8n-nodes-base.slack', name: 'Slack', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 } 162 | ], 163 | connections: {}, 164 | settings: {} 165 | } 166 | }; 167 | 168 | const categories = ['automation', 'integration']; 169 | 170 | repository.saveTemplate(workflow, detail, categories); 171 | 172 | const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.calls.find( 173 | call => call[0].includes('INSERT OR REPLACE INTO templates') 174 | )?.[0] || ''); 175 | 176 | // The implementation now uses gzip compression, so we just verify the call happened 177 | expect(stmt?.run).toHaveBeenCalledWith( 178 | 123, // id 179 | 123, // workflow_id 180 | 'Test Workflow', 181 | 'A test workflow', 182 | 'John Doe', 183 | 'johndoe', 184 | 1, // verified 185 | JSON.stringify(['n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack']), 186 | expect.any(String), // compressed workflow JSON 187 | JSON.stringify(['automation', 'integration']), 188 | 1000, // views 189 | '2024-01-01T00:00:00Z', 190 | '2024-01-01T00:00:00Z', 191 | 'https://n8n.io/workflows/123' 192 | ); 193 | }); 194 | }); 195 | 196 | describe('getTemplate', () => { 197 | it('should retrieve a specific template by ID', () => { 198 | const mockTemplate: StoredTemplate = { 199 | id: 123, 200 | workflow_id: 123, 201 | name: 'Test Template', 202 | description: 'Description', 203 | author_name: 'Author', 204 | author_username: 'author', 205 | author_verified: 1, 206 | nodes_used: '[]', 207 | workflow_json: '{}', 208 | categories: '[]', 209 | views: 500, 210 | created_at: '2024-01-01', 211 | updated_at: '2024-01-01', 212 | url: 'https://n8n.io/workflows/123', 213 | scraped_at: '2024-01-01' 214 | }; 215 | 216 | mockAdapter._setMockData('template:123', mockTemplate); 217 | 218 | const result = repository.getTemplate(123); 219 | 220 | expect(result).toEqual(mockTemplate); 221 | }); 222 | 223 | it('should return null for non-existent template', () => { 224 | const result = repository.getTemplate(999); 225 | expect(result).toBeNull(); 226 | }); 227 | }); 228 | 229 | describe('searchTemplates', () => { 230 | it('should use FTS5 search when available', () => { 231 | const ftsResults: StoredTemplate[] = [{ 232 | id: 1, 233 | workflow_id: 1, 234 | name: 'Chatbot Workflow', 235 | description: 'AI chatbot', 236 | author_name: 'Author', 237 | author_username: 'author', 238 | author_verified: 0, 239 | nodes_used: '[]', 240 | workflow_json: '{}', 241 | categories: '[]', 242 | views: 100, 243 | created_at: '2024-01-01', 244 | updated_at: '2024-01-01', 245 | url: 'https://n8n.io/workflows/1', 246 | scraped_at: '2024-01-01' 247 | }]; 248 | 249 | mockAdapter._setMockData('fts_results', ftsResults); 250 | 251 | const results = repository.searchTemplates('chatbot', 10); 252 | 253 | expect(results).toEqual(ftsResults); 254 | }); 255 | 256 | it('should fall back to LIKE search when FTS5 is not supported', () => { 257 | mockAdapter._setFTS5Support(false); 258 | const newRepo = new TemplateRepository(mockAdapter); 259 | 260 | const likeResults: StoredTemplate[] = [{ 261 | id: 3, 262 | workflow_id: 3, 263 | name: 'LIKE only', 264 | description: 'No FTS5', 265 | author_name: 'Author', 266 | author_username: 'author', 267 | author_verified: 0, 268 | nodes_used: '[]', 269 | workflow_json: '{}', 270 | categories: '[]', 271 | views: 25, 272 | created_at: '2024-01-01', 273 | updated_at: '2024-01-01', 274 | url: 'https://n8n.io/workflows/3', 275 | scraped_at: '2024-01-01' 276 | }]; 277 | 278 | mockAdapter._setMockData('like_results', likeResults); 279 | 280 | const results = newRepo.searchTemplates('test', 20); 281 | 282 | expect(results).toEqual(likeResults); 283 | }); 284 | }); 285 | 286 | describe('getTemplatesByNodes', () => { 287 | it('should find templates using specific node types', () => { 288 | const mockTemplates: StoredTemplate[] = [{ 289 | id: 1, 290 | workflow_id: 1, 291 | name: 'HTTP Workflow', 292 | description: 'Uses HTTP', 293 | author_name: 'Author', 294 | author_username: 'author', 295 | author_verified: 1, 296 | nodes_used: '["n8n-nodes-base.httpRequest"]', 297 | workflow_json: '{}', 298 | categories: '[]', 299 | views: 100, 300 | created_at: '2024-01-01', 301 | updated_at: '2024-01-01', 302 | url: 'https://n8n.io/workflows/1', 303 | scraped_at: '2024-01-01' 304 | }]; 305 | 306 | // Set up the mock to return our templates 307 | const stmt = new MockPreparedStatement('', new Map()); 308 | stmt.all = vi.fn(() => mockTemplates); 309 | mockAdapter.prepare = vi.fn(() => stmt); 310 | 311 | const results = repository.getTemplatesByNodes(['n8n-nodes-base.httpRequest'], 5); 312 | 313 | expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.httpRequest"%', 5, 0); 314 | expect(results).toEqual(mockTemplates); 315 | }); 316 | }); 317 | 318 | describe('getTemplatesForTask', () => { 319 | it('should return templates for known tasks', () => { 320 | const aiTemplates: StoredTemplate[] = [{ 321 | id: 1, 322 | workflow_id: 1, 323 | name: 'AI Workflow', 324 | description: 'Uses OpenAI', 325 | author_name: 'Author', 326 | author_username: 'author', 327 | author_verified: 1, 328 | nodes_used: '["@n8n/n8n-nodes-langchain.openAi"]', 329 | workflow_json: '{}', 330 | categories: '["ai"]', 331 | views: 1000, 332 | created_at: '2024-01-01', 333 | updated_at: '2024-01-01', 334 | url: 'https://n8n.io/workflows/1', 335 | scraped_at: '2024-01-01' 336 | }]; 337 | 338 | const stmt = new MockPreparedStatement('', new Map()); 339 | stmt.all = vi.fn(() => aiTemplates); 340 | mockAdapter.prepare = vi.fn(() => stmt); 341 | 342 | const results = repository.getTemplatesForTask('ai_automation'); 343 | 344 | expect(results).toEqual(aiTemplates); 345 | }); 346 | 347 | it('should return empty array for unknown task', () => { 348 | const results = repository.getTemplatesForTask('unknown_task'); 349 | expect(results).toEqual([]); 350 | }); 351 | }); 352 | 353 | describe('template statistics', () => { 354 | it('should get template count', () => { 355 | mockAdapter._setMockData('template_count', 42); 356 | 357 | const count = repository.getTemplateCount(); 358 | 359 | expect(count).toBe(42); 360 | }); 361 | 362 | it('should get template statistics', () => { 363 | mockAdapter._setMockData('template_count', 100); 364 | mockAdapter._setMockData('avg_views', 250.5); 365 | 366 | const topTemplates = [ 367 | { nodes_used: '["n8n-nodes-base.httpRequest", "n8n-nodes-base.slack"]' }, 368 | { nodes_used: '["n8n-nodes-base.httpRequest", "n8n-nodes-base.code"]' }, 369 | { nodes_used: '["n8n-nodes-base.slack"]' } 370 | ]; 371 | 372 | const stmt = new MockPreparedStatement('', new Map()); 373 | stmt.all = vi.fn(() => topTemplates); 374 | mockAdapter.prepare = vi.fn((sql) => { 375 | if (sql.includes('ORDER BY views DESC')) { 376 | return stmt; 377 | } 378 | return new MockPreparedStatement(sql, mockAdapter['mockData']); 379 | }); 380 | 381 | const stats = repository.getTemplateStats(); 382 | 383 | expect(stats.totalTemplates).toBe(100); 384 | expect(stats.averageViews).toBe(251); 385 | expect(stats.topUsedNodes).toContainEqual({ node: 'n8n-nodes-base.httpRequest', count: 2 }); 386 | }); 387 | }); 388 | 389 | describe('pagination count methods', () => { 390 | it('should get node templates count', () => { 391 | mockAdapter._setMockData('node_templates_count', 15); 392 | 393 | const stmt = new MockPreparedStatement('', new Map()); 394 | stmt.get = vi.fn(() => ({ count: 15 })); 395 | mockAdapter.prepare = vi.fn(() => stmt); 396 | 397 | const count = repository.getNodeTemplatesCount(['n8n-nodes-base.webhook']); 398 | 399 | expect(count).toBe(15); 400 | expect(stmt.get).toHaveBeenCalledWith('%"n8n-nodes-base.webhook"%'); 401 | }); 402 | 403 | it('should get search count', () => { 404 | const stmt = new MockPreparedStatement('', new Map()); 405 | stmt.get = vi.fn(() => ({ count: 8 })); 406 | mockAdapter.prepare = vi.fn(() => stmt); 407 | 408 | const count = repository.getSearchCount('webhook'); 409 | 410 | expect(count).toBe(8); 411 | }); 412 | 413 | it('should get task templates count', () => { 414 | const stmt = new MockPreparedStatement('', new Map()); 415 | stmt.get = vi.fn(() => ({ count: 12 })); 416 | mockAdapter.prepare = vi.fn(() => stmt); 417 | 418 | const count = repository.getTaskTemplatesCount('ai_automation'); 419 | 420 | expect(count).toBe(12); 421 | }); 422 | 423 | it('should handle pagination in getAllTemplates', () => { 424 | const mockTemplates = [ 425 | { id: 1, name: 'Template 1' }, 426 | { id: 2, name: 'Template 2' } 427 | ]; 428 | 429 | const stmt = new MockPreparedStatement('', new Map()); 430 | stmt.all = vi.fn(() => mockTemplates); 431 | mockAdapter.prepare = vi.fn(() => stmt); 432 | 433 | const results = repository.getAllTemplates(10, 5, 'name'); 434 | 435 | expect(results).toEqual(mockTemplates); 436 | expect(stmt.all).toHaveBeenCalledWith(10, 5); 437 | }); 438 | 439 | it('should handle pagination in getTemplatesByNodes', () => { 440 | const mockTemplates = [ 441 | { id: 1, nodes_used: '["n8n-nodes-base.webhook"]' } 442 | ]; 443 | 444 | const stmt = new MockPreparedStatement('', new Map()); 445 | stmt.all = vi.fn(() => mockTemplates); 446 | mockAdapter.prepare = vi.fn(() => stmt); 447 | 448 | const results = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 5, 10); 449 | 450 | expect(results).toEqual(mockTemplates); 451 | expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.webhook"%', 5, 10); 452 | }); 453 | 454 | it('should handle pagination in searchTemplates', () => { 455 | const mockTemplates = [ 456 | { id: 1, name: 'Search Result 1' } 457 | ]; 458 | 459 | mockAdapter._setMockData('fts_results', mockTemplates); 460 | 461 | const stmt = new MockPreparedStatement('', new Map()); 462 | stmt.all = vi.fn(() => mockTemplates); 463 | mockAdapter.prepare = vi.fn(() => stmt); 464 | 465 | const results = repository.searchTemplates('webhook', 20, 40); 466 | 467 | expect(results).toEqual(mockTemplates); 468 | }); 469 | 470 | it('should handle pagination in getTemplatesForTask', () => { 471 | const mockTemplates = [ 472 | { id: 1, categories: '["ai"]' } 473 | ]; 474 | 475 | const stmt = new MockPreparedStatement('', new Map()); 476 | stmt.all = vi.fn(() => mockTemplates); 477 | mockAdapter.prepare = vi.fn(() => stmt); 478 | 479 | const results = repository.getTemplatesForTask('ai_automation', 15, 30); 480 | 481 | expect(results).toEqual(mockTemplates); 482 | }); 483 | }); 484 | 485 | describe('maintenance operations', () => { 486 | it('should clear all templates', () => { 487 | repository.clearTemplates(); 488 | 489 | expect(mockAdapter.exec).toHaveBeenCalledWith('DELETE FROM templates'); 490 | }); 491 | 492 | it('should rebuild FTS5 index when supported', () => { 493 | repository.rebuildTemplateFTS(); 494 | 495 | expect(mockAdapter.exec).toHaveBeenCalledWith('DELETE FROM templates_fts'); 496 | expect(mockAdapter.exec).toHaveBeenCalledWith( 497 | expect.stringContaining('INSERT INTO templates_fts') 498 | ); 499 | }); 500 | }); 501 | }); ``` -------------------------------------------------------------------------------- /tests/unit/flexible-instance-security-advanced.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Advanced security and error handling tests for flexible instance configuration 3 | * 4 | * This test file focuses on advanced security scenarios, error handling edge cases, 5 | * and comprehensive testing of security-related code paths 6 | */ 7 | 8 | import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; 9 | import { InstanceContext, validateInstanceContext } from '../../src/types/instance-context'; 10 | import { getN8nApiClient } from '../../src/mcp/handlers-n8n-manager'; 11 | import { getN8nApiConfigFromContext } from '../../src/config/n8n-api'; 12 | import { N8nApiClient } from '../../src/services/n8n-api-client'; 13 | import { logger } from '../../src/utils/logger'; 14 | import { createHash } from 'crypto'; 15 | 16 | // Mock dependencies 17 | vi.mock('../../src/services/n8n-api-client'); 18 | vi.mock('../../src/config/n8n-api'); 19 | vi.mock('../../src/utils/logger'); 20 | 21 | describe('Advanced Security and Error Handling Tests', () => { 22 | let mockN8nApiClient: Mock; 23 | let mockGetN8nApiConfigFromContext: Mock; 24 | let mockLogger: any; // Logger mock has complex type 25 | 26 | beforeEach(() => { 27 | vi.resetAllMocks(); 28 | vi.resetModules(); 29 | 30 | mockN8nApiClient = vi.mocked(N8nApiClient); 31 | mockGetN8nApiConfigFromContext = vi.mocked(getN8nApiConfigFromContext); 32 | mockLogger = vi.mocked(logger); 33 | }); 34 | 35 | afterEach(() => { 36 | vi.clearAllMocks(); 37 | }); 38 | 39 | describe('Advanced Input Sanitization', () => { 40 | it('should handle SQL injection attempts in context fields', () => { 41 | const maliciousContext = { 42 | n8nApiUrl: "https://api.n8n.cloud'; DROP TABLE users; --", 43 | n8nApiKey: "key'; DELETE FROM secrets; --", 44 | instanceId: "'; SELECT * FROM passwords; --" 45 | }; 46 | 47 | const validation = validateInstanceContext(maliciousContext); 48 | 49 | // URL should be invalid due to special characters 50 | expect(validation.valid).toBe(false); 51 | expect(validation.errors?.some(error => error.startsWith('Invalid n8nApiUrl:'))).toBe(true); 52 | }); 53 | 54 | it('should handle XSS attempts in context fields', () => { 55 | const xssContext = { 56 | n8nApiUrl: 'https://api.n8n.cloud<script>alert("xss")</script>', 57 | n8nApiKey: '<img src=x onerror=alert("xss")>', 58 | instanceId: 'javascript:alert("xss")' 59 | }; 60 | 61 | const validation = validateInstanceContext(xssContext); 62 | 63 | // Should be invalid due to malformed URL 64 | expect(validation.valid).toBe(false); 65 | }); 66 | 67 | it('should handle extremely long input values', () => { 68 | const longString = 'a'.repeat(100000); 69 | const longContext: InstanceContext = { 70 | n8nApiUrl: `https://api.n8n.cloud/${longString}`, 71 | n8nApiKey: longString, 72 | instanceId: longString 73 | }; 74 | 75 | // Should handle without crashing 76 | expect(() => validateInstanceContext(longContext)).not.toThrow(); 77 | expect(() => getN8nApiClient(longContext)).not.toThrow(); 78 | }); 79 | 80 | it('should handle Unicode and special characters safely', () => { 81 | const unicodeContext: InstanceContext = { 82 | n8nApiUrl: 'https://api.n8n.cloud/测试', 83 | n8nApiKey: 'key-ñáéíóú-кириллица-🚀', 84 | instanceId: '用户-123-αβγ' 85 | }; 86 | 87 | expect(() => validateInstanceContext(unicodeContext)).not.toThrow(); 88 | expect(() => getN8nApiClient(unicodeContext)).not.toThrow(); 89 | }); 90 | 91 | it('should handle null bytes and control characters', () => { 92 | const maliciousContext = { 93 | n8nApiUrl: 'https://api.n8n.cloud\0\x01\x02', 94 | n8nApiKey: 'key\r\n\t\0', 95 | instanceId: 'instance\x00\x1f' 96 | }; 97 | 98 | expect(() => validateInstanceContext(maliciousContext)).not.toThrow(); 99 | }); 100 | }); 101 | 102 | describe('Prototype Pollution Protection', () => { 103 | it('should not be vulnerable to prototype pollution via __proto__', () => { 104 | const pollutionAttempt = { 105 | n8nApiUrl: 'https://api.n8n.cloud', 106 | n8nApiKey: 'test-key', 107 | __proto__: { 108 | isAdmin: true, 109 | polluted: 'value' 110 | } 111 | }; 112 | 113 | expect(() => validateInstanceContext(pollutionAttempt)).not.toThrow(); 114 | 115 | // Verify prototype wasn't polluted 116 | const cleanObject = {}; 117 | expect((cleanObject as any).isAdmin).toBeUndefined(); 118 | expect((cleanObject as any).polluted).toBeUndefined(); 119 | }); 120 | 121 | it('should not be vulnerable to prototype pollution via constructor', () => { 122 | const pollutionAttempt = { 123 | n8nApiUrl: 'https://api.n8n.cloud', 124 | n8nApiKey: 'test-key', 125 | constructor: { 126 | prototype: { 127 | isAdmin: true 128 | } 129 | } 130 | }; 131 | 132 | expect(() => validateInstanceContext(pollutionAttempt)).not.toThrow(); 133 | }); 134 | 135 | it('should handle Object.create(null) safely', () => { 136 | const nullProtoObject = Object.create(null); 137 | nullProtoObject.n8nApiUrl = 'https://api.n8n.cloud'; 138 | nullProtoObject.n8nApiKey = 'test-key'; 139 | 140 | expect(() => validateInstanceContext(nullProtoObject)).not.toThrow(); 141 | }); 142 | }); 143 | 144 | describe('Memory Exhaustion Protection', () => { 145 | it('should handle deeply nested objects without stack overflow', () => { 146 | let deepObject: any = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'key' }; 147 | for (let i = 0; i < 1000; i++) { 148 | deepObject = { nested: deepObject }; 149 | } 150 | deepObject.metadata = deepObject; 151 | 152 | expect(() => validateInstanceContext(deepObject)).not.toThrow(); 153 | }); 154 | 155 | it('should handle circular references in metadata', () => { 156 | const circularContext: any = { 157 | n8nApiUrl: 'https://api.n8n.cloud', 158 | n8nApiKey: 'test-key', 159 | metadata: {} 160 | }; 161 | circularContext.metadata.self = circularContext; 162 | circularContext.metadata.circular = circularContext.metadata; 163 | 164 | expect(() => validateInstanceContext(circularContext)).not.toThrow(); 165 | }); 166 | 167 | it('should handle massive arrays in metadata', () => { 168 | const massiveArray = new Array(100000).fill('data'); 169 | const arrayContext: InstanceContext = { 170 | n8nApiUrl: 'https://api.n8n.cloud', 171 | n8nApiKey: 'test-key', 172 | metadata: { 173 | massiveArray 174 | } 175 | }; 176 | 177 | expect(() => validateInstanceContext(arrayContext)).not.toThrow(); 178 | }); 179 | }); 180 | 181 | describe('Cache Security and Isolation', () => { 182 | it('should prevent cache key collisions through hash security', () => { 183 | mockGetN8nApiConfigFromContext.mockReturnValue({ 184 | baseUrl: 'https://api.n8n.cloud', 185 | apiKey: 'test-key', 186 | timeout: 30000, 187 | maxRetries: 3 188 | }); 189 | 190 | // Create contexts that might produce hash collisions 191 | const context1: InstanceContext = { 192 | n8nApiUrl: 'https://api.n8n.cloud', 193 | n8nApiKey: 'abc', 194 | instanceId: 'def' 195 | }; 196 | 197 | const context2: InstanceContext = { 198 | n8nApiUrl: 'https://api.n8n.cloud', 199 | n8nApiKey: 'ab', 200 | instanceId: 'cdef' 201 | }; 202 | 203 | const hash1 = createHash('sha256') 204 | .update(`${context1.n8nApiUrl}:${context1.n8nApiKey}:${context1.instanceId}`) 205 | .digest('hex'); 206 | 207 | const hash2 = createHash('sha256') 208 | .update(`${context2.n8nApiUrl}:${context2.n8nApiKey}:${context2.instanceId}`) 209 | .digest('hex'); 210 | 211 | expect(hash1).not.toBe(hash2); 212 | 213 | // Verify separate cache entries 214 | getN8nApiClient(context1); 215 | getN8nApiClient(context2); 216 | 217 | expect(mockN8nApiClient).toHaveBeenCalledTimes(2); 218 | }); 219 | 220 | it('should not expose sensitive data in cache key logs', () => { 221 | const loggerInfoSpy = vi.spyOn(logger, 'info'); 222 | const sensitiveContext: InstanceContext = { 223 | n8nApiUrl: 'https://super-secret-api.example.com/v1/secret', 224 | n8nApiKey: 'sk_live_SUPER_SECRET_API_KEY_123456789', 225 | instanceId: 'production-instance-sensitive' 226 | }; 227 | 228 | mockGetN8nApiConfigFromContext.mockReturnValue({ 229 | baseUrl: 'https://super-secret-api.example.com/v1/secret', 230 | apiKey: 'sk_live_SUPER_SECRET_API_KEY_123456789', 231 | timeout: 30000, 232 | maxRetries: 3 233 | }); 234 | 235 | getN8nApiClient(sensitiveContext); 236 | 237 | // Check all log calls 238 | const allLogData = loggerInfoSpy.mock.calls.flat().join(' '); 239 | 240 | // Should not contain sensitive data 241 | expect(allLogData).not.toContain('sk_live_SUPER_SECRET_API_KEY_123456789'); 242 | expect(allLogData).not.toContain('super-secret-api-key'); 243 | expect(allLogData).not.toContain('/v1/secret'); 244 | 245 | // Logs should not expose the actual API key value 246 | expect(allLogData).not.toContain('SUPER_SECRET'); 247 | }); 248 | 249 | it('should handle hash collisions securely', () => { 250 | // Mock a scenario where two different inputs could theoretically 251 | // produce the same hash (extremely unlikely with SHA-256) 252 | const context1: InstanceContext = { 253 | n8nApiUrl: 'https://api1.n8n.cloud', 254 | n8nApiKey: 'key1', 255 | instanceId: 'instance1' 256 | }; 257 | 258 | const context2: InstanceContext = { 259 | n8nApiUrl: 'https://api2.n8n.cloud', 260 | n8nApiKey: 'key2', 261 | instanceId: 'instance2' 262 | }; 263 | 264 | mockGetN8nApiConfigFromContext.mockReturnValue({ 265 | baseUrl: 'https://api.n8n.cloud', 266 | apiKey: 'test-key', 267 | timeout: 30000, 268 | maxRetries: 3 269 | }); 270 | 271 | // Even if hashes were identical, different configs would be isolated 272 | getN8nApiClient(context1); 273 | getN8nApiClient(context2); 274 | 275 | expect(mockN8nApiClient).toHaveBeenCalledTimes(2); 276 | }); 277 | }); 278 | 279 | describe('Error Message Security', () => { 280 | it('should not expose sensitive data in validation error messages', () => { 281 | const sensitiveContext: InstanceContext = { 282 | n8nApiUrl: 'https://secret-api.example.com/private-endpoint', 283 | n8nApiKey: 'super-secret-key-123', 284 | n8nApiTimeout: -1 285 | }; 286 | 287 | const validation = validateInstanceContext(sensitiveContext); 288 | 289 | expect(validation.valid).toBe(false); 290 | 291 | // Error messages should not contain sensitive data 292 | const errorMessage = validation.errors?.join(' ') || ''; 293 | expect(errorMessage).not.toContain('super-secret-key-123'); 294 | expect(errorMessage).not.toContain('secret-api'); 295 | expect(errorMessage).not.toContain('private-endpoint'); 296 | }); 297 | 298 | it('should sanitize error details in API responses', () => { 299 | const sensitiveContext: InstanceContext = { 300 | n8nApiUrl: 'invalid-url-with-secrets/api/key=secret123', 301 | n8nApiKey: 'another-secret-key' 302 | }; 303 | 304 | const validation = validateInstanceContext(sensitiveContext); 305 | 306 | expect(validation.valid).toBe(false); 307 | expect(validation.errors?.some(error => error.startsWith('Invalid n8nApiUrl:'))).toBe(true); 308 | 309 | // Should not contain the actual invalid URL 310 | const errorData = JSON.stringify(validation); 311 | expect(errorData).not.toContain('secret123'); 312 | expect(errorData).not.toContain('another-secret-key'); 313 | }); 314 | }); 315 | 316 | describe('Resource Exhaustion Protection', () => { 317 | it('should handle memory pressure gracefully', () => { 318 | // Create many large contexts to simulate memory pressure 319 | const largeData = 'x'.repeat(10000); 320 | 321 | for (let i = 0; i < 100; i++) { 322 | const context: InstanceContext = { 323 | n8nApiUrl: 'https://api.n8n.cloud', 324 | n8nApiKey: `key-${i}`, 325 | instanceId: `instance-${i}`, 326 | metadata: { 327 | largeData: largeData, 328 | moreData: new Array(1000).fill(largeData) 329 | } 330 | }; 331 | 332 | expect(() => validateInstanceContext(context)).not.toThrow(); 333 | } 334 | }); 335 | 336 | it('should handle high frequency validation requests', () => { 337 | const context: InstanceContext = { 338 | n8nApiUrl: 'https://api.n8n.cloud', 339 | n8nApiKey: 'frequency-test-key' 340 | }; 341 | 342 | // Rapid fire validation 343 | for (let i = 0; i < 1000; i++) { 344 | expect(() => validateInstanceContext(context)).not.toThrow(); 345 | } 346 | }); 347 | }); 348 | 349 | describe('Cryptographic Security', () => { 350 | it('should use cryptographically secure hash function', () => { 351 | const context: InstanceContext = { 352 | n8nApiUrl: 'https://api.n8n.cloud', 353 | n8nApiKey: 'crypto-test-key', 354 | instanceId: 'crypto-instance' 355 | }; 356 | 357 | // Generate hash multiple times - should be deterministic 358 | const hash1 = createHash('sha256') 359 | .update(`${context.n8nApiUrl}:${context.n8nApiKey}:${context.instanceId}`) 360 | .digest('hex'); 361 | 362 | const hash2 = createHash('sha256') 363 | .update(`${context.n8nApiUrl}:${context.n8nApiKey}:${context.instanceId}`) 364 | .digest('hex'); 365 | 366 | expect(hash1).toBe(hash2); 367 | expect(hash1).toHaveLength(64); // SHA-256 produces 64-character hex string 368 | expect(hash1).toMatch(/^[a-f0-9]{64}$/); 369 | }); 370 | 371 | it('should handle edge cases in hash input', () => { 372 | const edgeCases = [ 373 | { url: '', key: '', id: '' }, 374 | { url: 'https://api.n8n.cloud', key: '', id: '' }, 375 | { url: '', key: 'key', id: '' }, 376 | { url: '', key: '', id: 'id' }, 377 | { url: undefined, key: undefined, id: undefined } 378 | ]; 379 | 380 | edgeCases.forEach((testCase, index) => { 381 | expect(() => { 382 | createHash('sha256') 383 | .update(`${testCase.url || ''}:${testCase.key || ''}:${testCase.id || ''}`) 384 | .digest('hex'); 385 | }).not.toThrow(); 386 | }); 387 | }); 388 | }); 389 | 390 | describe('Injection Attack Prevention', () => { 391 | it('should prevent command injection through context fields', () => { 392 | const commandInjectionContext = { 393 | n8nApiUrl: 'https://api.n8n.cloud; rm -rf /', 394 | n8nApiKey: '$(whoami)', 395 | instanceId: '`cat /etc/passwd`' 396 | }; 397 | 398 | expect(() => validateInstanceContext(commandInjectionContext)).not.toThrow(); 399 | 400 | // URL should be invalid 401 | const validation = validateInstanceContext(commandInjectionContext); 402 | expect(validation.valid).toBe(false); 403 | }); 404 | 405 | it('should prevent path traversal attempts', () => { 406 | const pathTraversalContext = { 407 | n8nApiUrl: 'https://api.n8n.cloud/../../../etc/passwd', 408 | n8nApiKey: '..\\..\\windows\\system32\\config\\sam', 409 | instanceId: '../secrets.txt' 410 | }; 411 | 412 | expect(() => validateInstanceContext(pathTraversalContext)).not.toThrow(); 413 | }); 414 | 415 | it('should prevent LDAP injection attempts', () => { 416 | const ldapInjectionContext = { 417 | n8nApiUrl: 'https://api.n8n.cloud)(|(password=*))', 418 | n8nApiKey: '*)(uid=*', 419 | instanceId: '*))(|(cn=*' 420 | }; 421 | 422 | expect(() => validateInstanceContext(ldapInjectionContext)).not.toThrow(); 423 | }); 424 | }); 425 | 426 | describe('State Management Security', () => { 427 | it('should maintain isolation between contexts', () => { 428 | const context1: InstanceContext = { 429 | n8nApiUrl: 'https://tenant1.n8n.cloud', 430 | n8nApiKey: 'tenant1-key', 431 | instanceId: 'tenant1' 432 | }; 433 | 434 | const context2: InstanceContext = { 435 | n8nApiUrl: 'https://tenant2.n8n.cloud', 436 | n8nApiKey: 'tenant2-key', 437 | instanceId: 'tenant2' 438 | }; 439 | 440 | mockGetN8nApiConfigFromContext 441 | .mockReturnValueOnce({ 442 | baseUrl: 'https://tenant1.n8n.cloud', 443 | apiKey: 'tenant1-key', 444 | timeout: 30000, 445 | maxRetries: 3 446 | }) 447 | .mockReturnValueOnce({ 448 | baseUrl: 'https://tenant2.n8n.cloud', 449 | apiKey: 'tenant2-key', 450 | timeout: 30000, 451 | maxRetries: 3 452 | }); 453 | 454 | const client1 = getN8nApiClient(context1); 455 | const client2 = getN8nApiClient(context2); 456 | 457 | // Should create separate clients 458 | expect(mockN8nApiClient).toHaveBeenCalledTimes(2); 459 | expect(client1).not.toBe(client2); 460 | }); 461 | 462 | it('should handle concurrent access securely', async () => { 463 | const contexts = Array(50).fill(null).map((_, i) => ({ 464 | n8nApiUrl: 'https://api.n8n.cloud', 465 | n8nApiKey: `concurrent-key-${i}`, 466 | instanceId: `concurrent-${i}` 467 | })); 468 | 469 | mockGetN8nApiConfigFromContext.mockReturnValue({ 470 | baseUrl: 'https://api.n8n.cloud', 471 | apiKey: 'test-key', 472 | timeout: 30000, 473 | maxRetries: 3 474 | }); 475 | 476 | // Simulate concurrent access 477 | const promises = contexts.map(context => 478 | Promise.resolve(getN8nApiClient(context)) 479 | ); 480 | 481 | const results = await Promise.all(promises); 482 | 483 | // All should succeed 484 | results.forEach(result => { 485 | expect(result).toBeDefined(); 486 | }); 487 | 488 | expect(mockN8nApiClient).toHaveBeenCalledTimes(50); 489 | }); 490 | }); 491 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-expression-format.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { WorkflowValidator } from '../../../src/services/workflow-validator'; 3 | import { NodeRepository } from '../../../src/database/node-repository'; 4 | import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; 5 | 6 | // Mock the database 7 | vi.mock('../../../src/database/node-repository'); 8 | 9 | describe('WorkflowValidator - Expression Format Validation', () => { 10 | let validator: WorkflowValidator; 11 | let mockNodeRepository: any; 12 | 13 | beforeEach(() => { 14 | // Create mock repository 15 | mockNodeRepository = { 16 | findNodeByType: vi.fn().mockImplementation((type: string) => { 17 | // Return mock nodes for common types 18 | if (type === 'n8n-nodes-base.emailSend') { 19 | return { 20 | node_type: 'n8n-nodes-base.emailSend', 21 | display_name: 'Email Send', 22 | properties: {}, 23 | version: 2.1 24 | }; 25 | } 26 | if (type === 'n8n-nodes-base.github') { 27 | return { 28 | node_type: 'n8n-nodes-base.github', 29 | display_name: 'GitHub', 30 | properties: {}, 31 | version: 1.1 32 | }; 33 | } 34 | if (type === 'n8n-nodes-base.webhook') { 35 | return { 36 | node_type: 'n8n-nodes-base.webhook', 37 | display_name: 'Webhook', 38 | properties: {}, 39 | version: 1 40 | }; 41 | } 42 | if (type === 'n8n-nodes-base.httpRequest') { 43 | return { 44 | node_type: 'n8n-nodes-base.httpRequest', 45 | display_name: 'HTTP Request', 46 | properties: {}, 47 | version: 4 48 | }; 49 | } 50 | return null; 51 | }), 52 | searchNodes: vi.fn().mockReturnValue([]), 53 | getAllNodes: vi.fn().mockReturnValue([]), 54 | close: vi.fn() 55 | }; 56 | 57 | validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); 58 | }); 59 | 60 | describe('Expression Format Detection', () => { 61 | it('should detect missing = prefix in simple expressions', async () => { 62 | const workflow = { 63 | nodes: [ 64 | { 65 | id: '1', 66 | name: 'Send Email', 67 | type: 'n8n-nodes-base.emailSend', 68 | position: [0, 0] as [number, number], 69 | parameters: { 70 | fromEmail: '{{ $env.SENDER_EMAIL }}', 71 | toEmail: '[email protected]', 72 | subject: 'Test Email' 73 | }, 74 | typeVersion: 2.1 75 | } 76 | ], 77 | connections: {} 78 | }; 79 | 80 | const result = await validator.validateWorkflow(workflow); 81 | 82 | expect(result.valid).toBe(false); 83 | 84 | // Find expression format errors 85 | const formatErrors = result.errors.filter(e => e.message.includes('Expression format error')); 86 | expect(formatErrors).toHaveLength(1); 87 | 88 | const error = formatErrors[0]; 89 | expect(error.message).toContain('Expression format error'); 90 | expect(error.message).toContain('fromEmail'); 91 | expect(error.message).toContain('{{ $env.SENDER_EMAIL }}'); 92 | expect(error.message).toContain('={{ $env.SENDER_EMAIL }}'); 93 | }); 94 | 95 | it('should detect missing resource locator format for GitHub fields', async () => { 96 | const workflow = { 97 | nodes: [ 98 | { 99 | id: '1', 100 | name: 'GitHub', 101 | type: 'n8n-nodes-base.github', 102 | position: [0, 0] as [number, number], 103 | parameters: { 104 | operation: 'createComment', 105 | owner: '{{ $vars.GITHUB_OWNER }}', 106 | repository: '{{ $vars.GITHUB_REPO }}', 107 | issueNumber: 123, 108 | body: 'Test comment' 109 | }, 110 | typeVersion: 1.1 111 | } 112 | ], 113 | connections: {} 114 | }; 115 | 116 | const result = await validator.validateWorkflow(workflow); 117 | 118 | expect(result.valid).toBe(false); 119 | // Should have errors for both owner and repository 120 | const ownerError = result.errors.find(e => e.message.includes('owner')); 121 | const repoError = result.errors.find(e => e.message.includes('repository')); 122 | 123 | expect(ownerError).toBeTruthy(); 124 | expect(repoError).toBeTruthy(); 125 | expect(ownerError?.message).toContain('resource locator format'); 126 | expect(ownerError?.message).toContain('__rl'); 127 | }); 128 | 129 | it('should detect mixed content without prefix', async () => { 130 | const workflow = { 131 | nodes: [ 132 | { 133 | id: '1', 134 | name: 'HTTP Request', 135 | type: 'n8n-nodes-base.httpRequest', 136 | position: [0, 0] as [number, number], 137 | parameters: { 138 | url: 'https://api.example.com/{{ $json.endpoint }}', 139 | headers: { 140 | Authorization: 'Bearer {{ $env.API_TOKEN }}' 141 | } 142 | }, 143 | typeVersion: 4 144 | } 145 | ], 146 | connections: {} 147 | }; 148 | 149 | const result = await validator.validateWorkflow(workflow); 150 | 151 | expect(result.valid).toBe(false); 152 | const errors = result.errors.filter(e => e.message.includes('Expression format')); 153 | expect(errors.length).toBeGreaterThan(0); 154 | 155 | // Check for URL error 156 | const urlError = errors.find(e => e.message.includes('url')); 157 | expect(urlError).toBeTruthy(); 158 | expect(urlError?.message).toContain('=https://api.example.com/{{ $json.endpoint }}'); 159 | }); 160 | 161 | it('should accept properly formatted expressions', async () => { 162 | const workflow = { 163 | nodes: [ 164 | { 165 | id: '1', 166 | name: 'Send Email', 167 | type: 'n8n-nodes-base.emailSend', 168 | position: [0, 0] as [number, number], 169 | parameters: { 170 | fromEmail: '={{ $env.SENDER_EMAIL }}', 171 | toEmail: '[email protected]', 172 | subject: '=Test {{ $json.type }}' 173 | }, 174 | typeVersion: 2.1 175 | } 176 | ], 177 | connections: {} 178 | }; 179 | 180 | const result = await validator.validateWorkflow(workflow); 181 | 182 | // Should have no expression format errors 183 | const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); 184 | expect(formatErrors).toHaveLength(0); 185 | }); 186 | 187 | it('should accept resource locator format', async () => { 188 | const workflow = { 189 | nodes: [ 190 | { 191 | id: '1', 192 | name: 'GitHub', 193 | type: 'n8n-nodes-base.github', 194 | position: [0, 0] as [number, number], 195 | parameters: { 196 | operation: 'createComment', 197 | owner: { 198 | __rl: true, 199 | value: '={{ $vars.GITHUB_OWNER }}', 200 | mode: 'expression' 201 | }, 202 | repository: { 203 | __rl: true, 204 | value: '={{ $vars.GITHUB_REPO }}', 205 | mode: 'expression' 206 | }, 207 | issueNumber: 123, 208 | body: '=Test comment from {{ $json.author }}' 209 | }, 210 | typeVersion: 1.1 211 | } 212 | ], 213 | connections: {} 214 | }; 215 | 216 | const result = await validator.validateWorkflow(workflow); 217 | 218 | // Should have no expression format errors 219 | const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); 220 | expect(formatErrors).toHaveLength(0); 221 | }); 222 | 223 | it('should validate nested expressions in complex parameters', async () => { 224 | const workflow = { 225 | nodes: [ 226 | { 227 | id: '1', 228 | name: 'HTTP Request', 229 | type: 'n8n-nodes-base.httpRequest', 230 | position: [0, 0] as [number, number], 231 | parameters: { 232 | method: 'POST', 233 | url: 'https://api.example.com', 234 | sendBody: true, 235 | bodyParameters: { 236 | parameters: [ 237 | { 238 | name: 'userId', 239 | value: '{{ $json.id }}' 240 | }, 241 | { 242 | name: 'timestamp', 243 | value: '={{ $now }}' 244 | } 245 | ] 246 | } 247 | }, 248 | typeVersion: 4 249 | } 250 | ], 251 | connections: {} 252 | }; 253 | 254 | const result = await validator.validateWorkflow(workflow); 255 | 256 | // Should detect the missing prefix in nested parameter 257 | const errors = result.errors.filter(e => e.message.includes('Expression format')); 258 | expect(errors.length).toBeGreaterThan(0); 259 | 260 | const nestedError = errors.find(e => e.message.includes('bodyParameters')); 261 | expect(nestedError).toBeTruthy(); 262 | }); 263 | 264 | it('should warn about RL format even with prefix', async () => { 265 | const workflow = { 266 | nodes: [ 267 | { 268 | id: '1', 269 | name: 'GitHub', 270 | type: 'n8n-nodes-base.github', 271 | position: [0, 0] as [number, number], 272 | parameters: { 273 | operation: 'createComment', 274 | owner: '={{ $vars.GITHUB_OWNER }}', 275 | repository: '={{ $vars.GITHUB_REPO }}', 276 | issueNumber: 123, 277 | body: 'Test' 278 | }, 279 | typeVersion: 1.1 280 | } 281 | ], 282 | connections: {} 283 | }; 284 | 285 | const result = await validator.validateWorkflow(workflow); 286 | 287 | // Should have warnings about using RL format 288 | const warnings = result.warnings.filter(w => w.message.includes('resource locator format')); 289 | expect(warnings.length).toBeGreaterThan(0); 290 | }); 291 | }); 292 | 293 | describe('Real-world workflow examples', () => { 294 | it('should validate Email workflow with expression issues', async () => { 295 | const workflow = { 296 | name: 'Error Notification Workflow', 297 | nodes: [ 298 | { 299 | id: 'webhook-1', 300 | name: 'Webhook', 301 | type: 'n8n-nodes-base.webhook', 302 | position: [250, 300] as [number, number], 303 | parameters: { 304 | path: 'error-handler', 305 | httpMethod: 'POST' 306 | }, 307 | typeVersion: 1 308 | }, 309 | { 310 | id: 'email-1', 311 | name: 'Error Handler', 312 | type: 'n8n-nodes-base.emailSend', 313 | position: [450, 300] as [number, number], 314 | parameters: { 315 | fromEmail: '{{ $env.ADMIN_EMAIL }}', 316 | toEmail: '[email protected]', 317 | subject: 'Error in {{ $json.workflow }}', 318 | message: 'An error occurred: {{ $json.error }}', 319 | options: { 320 | replyTo: '={{ $env.SUPPORT_EMAIL }}' 321 | } 322 | }, 323 | typeVersion: 2.1 324 | } 325 | ], 326 | connections: { 327 | 'Webhook': { 328 | main: [[{ node: 'Error Handler', type: 'main', index: 0 }]] 329 | } 330 | } 331 | }; 332 | 333 | const result = await validator.validateWorkflow(workflow); 334 | 335 | // Should have multiple expression format errors 336 | const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); 337 | expect(formatErrors.length).toBeGreaterThanOrEqual(3); // fromEmail, subject, message 338 | 339 | // Check specific errors 340 | const fromEmailError = formatErrors.find(e => e.message.includes('fromEmail')); 341 | expect(fromEmailError).toBeTruthy(); 342 | expect(fromEmailError?.message).toContain('={{ $env.ADMIN_EMAIL }}'); 343 | }); 344 | 345 | it('should validate GitHub workflow with resource locator issues', async () => { 346 | const workflow = { 347 | name: 'GitHub Issue Handler', 348 | nodes: [ 349 | { 350 | id: 'webhook-1', 351 | name: 'Issue Webhook', 352 | type: 'n8n-nodes-base.webhook', 353 | position: [250, 300] as [number, number], 354 | parameters: { 355 | path: 'github-issue', 356 | httpMethod: 'POST' 357 | }, 358 | typeVersion: 1 359 | }, 360 | { 361 | id: 'github-1', 362 | name: 'Create Comment', 363 | type: 'n8n-nodes-base.github', 364 | position: [450, 300] as [number, number], 365 | parameters: { 366 | operation: 'createComment', 367 | owner: '{{ $vars.GITHUB_OWNER }}', 368 | repository: '{{ $vars.GITHUB_REPO }}', 369 | issueNumber: '={{ $json.body.issue.number }}', 370 | body: 'Thanks for the issue @{{ $json.body.issue.user.login }}!' 371 | }, 372 | typeVersion: 1.1 373 | } 374 | ], 375 | connections: { 376 | 'Issue Webhook': { 377 | main: [[{ node: 'Create Comment', type: 'main', index: 0 }]] 378 | } 379 | } 380 | }; 381 | 382 | const result = await validator.validateWorkflow(workflow); 383 | 384 | // Should have errors for owner, repository, and body 385 | const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); 386 | expect(formatErrors.length).toBeGreaterThanOrEqual(3); 387 | 388 | // Check for resource locator suggestions 389 | const ownerError = formatErrors.find(e => e.message.includes('owner')); 390 | expect(ownerError?.message).toContain('__rl'); 391 | expect(ownerError?.message).toContain('resource locator format'); 392 | }); 393 | 394 | it('should provide clear fix examples in error messages', async () => { 395 | const workflow = { 396 | nodes: [ 397 | { 398 | id: '1', 399 | name: 'Process Data', 400 | type: 'n8n-nodes-base.httpRequest', 401 | position: [0, 0] as [number, number], 402 | parameters: { 403 | url: 'https://api.example.com/users/{{ $json.userId }}' 404 | }, 405 | typeVersion: 4 406 | } 407 | ], 408 | connections: {} 409 | }; 410 | 411 | const result = await validator.validateWorkflow(workflow); 412 | 413 | const error = result.errors.find(e => e.message.includes('Expression format')); 414 | expect(error).toBeTruthy(); 415 | 416 | // Error message should contain both incorrect and correct examples 417 | expect(error?.message).toContain('Current (incorrect):'); 418 | expect(error?.message).toContain('"url": "https://api.example.com/users/{{ $json.userId }}"'); 419 | expect(error?.message).toContain('Fixed (correct):'); 420 | expect(error?.message).toContain('"url": "=https://api.example.com/users/{{ $json.userId }}"'); 421 | }); 422 | }); 423 | 424 | describe('Integration with other validations', () => { 425 | it('should validate expression format alongside syntax', async () => { 426 | const workflow = { 427 | nodes: [ 428 | { 429 | id: '1', 430 | name: 'Test Node', 431 | type: 'n8n-nodes-base.httpRequest', 432 | position: [0, 0] as [number, number], 433 | parameters: { 434 | url: '{{ $json.url', // Syntax error: unclosed expression 435 | headers: { 436 | 'X-Token': '{{ $env.TOKEN }}' // Format error: missing prefix 437 | } 438 | }, 439 | typeVersion: 4 440 | } 441 | ], 442 | connections: {} 443 | }; 444 | 445 | const result = await validator.validateWorkflow(workflow); 446 | 447 | // Should have both syntax and format errors 448 | const syntaxErrors = result.errors.filter(e => e.message.includes('Unmatched expression brackets')); 449 | const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); 450 | 451 | expect(syntaxErrors.length).toBeGreaterThan(0); 452 | expect(formatErrors.length).toBeGreaterThan(0); 453 | }); 454 | 455 | it('should not interfere with node validation', async () => { 456 | // Test that expression format validation works alongside other validations 457 | const workflow = { 458 | nodes: [ 459 | { 460 | id: '1', 461 | name: 'HTTP Request', 462 | type: 'n8n-nodes-base.httpRequest', 463 | position: [0, 0] as [number, number], 464 | parameters: { 465 | url: '{{ $json.endpoint }}', // Expression format error 466 | headers: { 467 | Authorization: '={{ $env.TOKEN }}' // Correct format 468 | } 469 | }, 470 | typeVersion: 4 471 | } 472 | ], 473 | connections: {} 474 | }; 475 | 476 | const result = await validator.validateWorkflow(workflow); 477 | 478 | // Should have expression format error for url field 479 | const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); 480 | expect(formatErrors).toHaveLength(1); 481 | expect(formatErrors[0].message).toContain('url'); 482 | 483 | // The workflow should still have structure validation (no trigger warning, etc) 484 | // This proves that expression validation doesn't interfere with other checks 485 | expect(result.warnings.some(w => w.message.includes('trigger'))).toBe(true); 486 | }); 487 | }); 488 | }); ``` -------------------------------------------------------------------------------- /docs/LIBRARY_USAGE.md: -------------------------------------------------------------------------------- ```markdown 1 | # Library Usage Guide - Multi-Tenant / Hosted Deployments 2 | 3 | This guide covers using n8n-mcp as a library dependency for building multi-tenant hosted services. 4 | 5 | ## Overview 6 | 7 | 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. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install n8n-mcp 13 | ``` 14 | 15 | ## Core Concepts 16 | 17 | ### Library Mode vs CLI Mode 18 | 19 | - **CLI Mode** (default): Single-player usage via `npx n8n-mcp` or Docker 20 | - **Library Mode**: Multi-tenant usage by importing and using the `N8NMCPEngine` class 21 | 22 | ### Instance Context 23 | 24 | The `InstanceContext` type allows you to pass per-request configuration to the MCP engine: 25 | 26 | ```typescript 27 | interface InstanceContext { 28 | // Instance-specific n8n API configuration 29 | n8nApiUrl?: string; 30 | n8nApiKey?: string; 31 | n8nApiTimeout?: number; 32 | n8nApiMaxRetries?: number; 33 | 34 | // Instance identification 35 | instanceId?: string; 36 | sessionId?: string; 37 | 38 | // Extensible metadata 39 | metadata?: Record<string, any>; 40 | } 41 | ``` 42 | 43 | ## Basic Example 44 | 45 | ```typescript 46 | import express from 'express'; 47 | import { N8NMCPEngine } from 'n8n-mcp'; 48 | 49 | const app = express(); 50 | const mcpEngine = new N8NMCPEngine({ 51 | sessionTimeout: 3600000, // 1 hour 52 | logLevel: 'info' 53 | }); 54 | 55 | // Handle MCP requests with per-user context 56 | app.post('/mcp', async (req, res) => { 57 | const instanceContext = { 58 | n8nApiUrl: req.user.n8nUrl, 59 | n8nApiKey: req.user.n8nApiKey, 60 | instanceId: req.user.id 61 | }; 62 | 63 | await mcpEngine.processRequest(req, res, instanceContext); 64 | }); 65 | 66 | app.listen(3000); 67 | ``` 68 | 69 | ## Multi-Tenant Backend Example 70 | 71 | This example shows a complete multi-tenant implementation with user authentication and instance management: 72 | 73 | ```typescript 74 | import express from 'express'; 75 | import { N8NMCPEngine, InstanceContext, validateInstanceContext } from 'n8n-mcp'; 76 | 77 | const app = express(); 78 | const mcpEngine = new N8NMCPEngine({ 79 | sessionTimeout: 3600000, // 1 hour 80 | logLevel: 'info' 81 | }); 82 | 83 | // Start MCP engine 84 | await mcpEngine.start(); 85 | 86 | // Authentication middleware 87 | const authenticate = async (req, res, next) => { 88 | const token = req.headers.authorization?.replace('Bearer ', ''); 89 | if (!token) { 90 | return res.status(401).json({ error: 'Unauthorized' }); 91 | } 92 | 93 | // Verify token and attach user to request 94 | req.user = await getUserFromToken(token); 95 | next(); 96 | }; 97 | 98 | // Get instance configuration from database 99 | const getInstanceConfig = async (instanceId: string, userId: string) => { 100 | // Your database logic here 101 | const instance = await db.instances.findOne({ 102 | where: { id: instanceId, userId } 103 | }); 104 | 105 | if (!instance) { 106 | throw new Error('Instance not found'); 107 | } 108 | 109 | return { 110 | n8nApiUrl: instance.n8nUrl, 111 | n8nApiKey: await decryptApiKey(instance.encryptedApiKey), 112 | instanceId: instance.id 113 | }; 114 | }; 115 | 116 | // MCP endpoint with per-instance context 117 | app.post('/api/instances/:instanceId/mcp', authenticate, async (req, res) => { 118 | try { 119 | // Get instance configuration 120 | const instance = await getInstanceConfig(req.params.instanceId, req.user.id); 121 | 122 | // Create instance context 123 | const context: InstanceContext = { 124 | n8nApiUrl: instance.n8nApiUrl, 125 | n8nApiKey: instance.n8nApiKey, 126 | instanceId: instance.instanceId, 127 | metadata: { 128 | userId: req.user.id, 129 | userAgent: req.headers['user-agent'], 130 | ip: req.ip 131 | } 132 | }; 133 | 134 | // Validate context before processing 135 | const validation = validateInstanceContext(context); 136 | if (!validation.valid) { 137 | return res.status(400).json({ 138 | error: 'Invalid instance configuration', 139 | details: validation.errors 140 | }); 141 | } 142 | 143 | // Process request with instance context 144 | await mcpEngine.processRequest(req, res, context); 145 | 146 | } catch (error) { 147 | console.error('MCP request error:', error); 148 | res.status(500).json({ error: 'Internal server error' }); 149 | } 150 | }); 151 | 152 | // Health endpoint 153 | app.get('/health', async (req, res) => { 154 | const health = await mcpEngine.healthCheck(); 155 | res.status(health.status === 'healthy' ? 200 : 503).json(health); 156 | }); 157 | 158 | // Graceful shutdown 159 | process.on('SIGTERM', async () => { 160 | await mcpEngine.shutdown(); 161 | process.exit(0); 162 | }); 163 | 164 | app.listen(3000); 165 | ``` 166 | 167 | ## API Reference 168 | 169 | ### N8NMCPEngine 170 | 171 | #### Constructor 172 | 173 | ```typescript 174 | new N8NMCPEngine(options?: { 175 | sessionTimeout?: number; // Session TTL in ms (default: 1800000 = 30min) 176 | logLevel?: 'error' | 'warn' | 'info' | 'debug'; // Default: 'info' 177 | }) 178 | ``` 179 | 180 | #### Methods 181 | 182 | ##### `async processRequest(req, res, context?)` 183 | 184 | Process a single MCP request with optional instance context. 185 | 186 | **Parameters:** 187 | - `req`: Express request object 188 | - `res`: Express response object 189 | - `context` (optional): InstanceContext with per-instance configuration 190 | 191 | **Example:** 192 | ```typescript 193 | const context: InstanceContext = { 194 | n8nApiUrl: 'https://instance1.n8n.cloud', 195 | n8nApiKey: 'instance1-key', 196 | instanceId: 'tenant-123' 197 | }; 198 | 199 | await engine.processRequest(req, res, context); 200 | ``` 201 | 202 | ##### `async healthCheck()` 203 | 204 | Get engine health status for monitoring. 205 | 206 | **Returns:** `EngineHealth` 207 | ```typescript 208 | { 209 | status: 'healthy' | 'unhealthy'; 210 | uptime: number; // seconds 211 | sessionActive: boolean; 212 | memoryUsage: { 213 | used: number; 214 | total: number; 215 | unit: string; 216 | }; 217 | version: string; 218 | } 219 | ``` 220 | 221 | **Example:** 222 | ```typescript 223 | app.get('/health', async (req, res) => { 224 | const health = await engine.healthCheck(); 225 | res.status(health.status === 'healthy' ? 200 : 503).json(health); 226 | }); 227 | ``` 228 | 229 | ##### `getSessionInfo()` 230 | 231 | Get current session information for debugging. 232 | 233 | **Returns:** 234 | ```typescript 235 | { 236 | active: boolean; 237 | sessionId?: string; 238 | age?: number; // milliseconds 239 | sessions?: { 240 | total: number; 241 | active: number; 242 | expired: number; 243 | max: number; 244 | sessionIds: string[]; 245 | }; 246 | } 247 | ``` 248 | 249 | ##### `async start()` 250 | 251 | Start the engine (for standalone mode). Not needed when using `processRequest()` directly. 252 | 253 | ##### `async shutdown()` 254 | 255 | Graceful shutdown for service lifecycle management. 256 | 257 | **Example:** 258 | ```typescript 259 | process.on('SIGTERM', async () => { 260 | await engine.shutdown(); 261 | process.exit(0); 262 | }); 263 | ``` 264 | 265 | ### Types 266 | 267 | #### InstanceContext 268 | 269 | Configuration for a specific user instance: 270 | 271 | ```typescript 272 | interface InstanceContext { 273 | n8nApiUrl?: string; 274 | n8nApiKey?: string; 275 | n8nApiTimeout?: number; 276 | n8nApiMaxRetries?: number; 277 | instanceId?: string; 278 | sessionId?: string; 279 | metadata?: Record<string, any>; 280 | } 281 | ``` 282 | 283 | #### Validation Functions 284 | 285 | ##### `validateInstanceContext(context: InstanceContext)` 286 | 287 | Validate and sanitize instance context. 288 | 289 | **Returns:** 290 | ```typescript 291 | { 292 | valid: boolean; 293 | errors?: string[]; 294 | } 295 | ``` 296 | 297 | **Example:** 298 | ```typescript 299 | import { validateInstanceContext } from 'n8n-mcp'; 300 | 301 | const validation = validateInstanceContext(context); 302 | if (!validation.valid) { 303 | console.error('Invalid context:', validation.errors); 304 | } 305 | ``` 306 | 307 | ##### `isInstanceContext(obj: any)` 308 | 309 | Type guard to check if an object is a valid InstanceContext. 310 | 311 | **Example:** 312 | ```typescript 313 | import { isInstanceContext } from 'n8n-mcp'; 314 | 315 | if (isInstanceContext(req.body.context)) { 316 | // TypeScript knows this is InstanceContext 317 | await engine.processRequest(req, res, req.body.context); 318 | } 319 | ``` 320 | 321 | ## Session Management 322 | 323 | ### Session Strategies 324 | 325 | The MCP engine supports flexible session ID formats: 326 | 327 | - **UUIDv4**: Internal n8n-mcp format (default) 328 | - **Instance-prefixed**: `instance-{userId}-{hash}-{uuid}` for multi-tenant isolation 329 | - **Custom formats**: Any non-empty string for mcp-remote and other proxies 330 | 331 | Session validation happens via transport lookup, not format validation. This ensures compatibility with all MCP clients. 332 | 333 | ### Multi-Tenant Configuration 334 | 335 | Set these environment variables for multi-tenant mode: 336 | 337 | ```bash 338 | # Enable multi-tenant mode 339 | ENABLE_MULTI_TENANT=true 340 | 341 | # Session strategy: "instance" (default) or "shared" 342 | MULTI_TENANT_SESSION_STRATEGY=instance 343 | ``` 344 | 345 | **Session Strategies:** 346 | 347 | - **instance** (recommended): Each tenant gets isolated sessions 348 | - Session ID: `instance-{instanceId}-{configHash}-{uuid}` 349 | - Better isolation and security 350 | - Easier debugging per tenant 351 | 352 | - **shared**: Multiple tenants share sessions with context switching 353 | - More efficient for high tenant count 354 | - Requires careful context management 355 | 356 | ## Security Considerations 357 | 358 | ### API Key Management 359 | 360 | Always encrypt API keys server-side: 361 | 362 | ```typescript 363 | import { createCipheriv, createDecipheriv } from 'crypto'; 364 | 365 | // Encrypt before storing 366 | const encryptApiKey = (apiKey: string) => { 367 | const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv); 368 | return cipher.update(apiKey, 'utf8', 'hex') + cipher.final('hex'); 369 | }; 370 | 371 | // Decrypt before using 372 | const decryptApiKey = (encrypted: string) => { 373 | const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv); 374 | return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8'); 375 | }; 376 | 377 | // Use decrypted key in context 378 | const context: InstanceContext = { 379 | n8nApiKey: await decryptApiKey(instance.encryptedApiKey), 380 | // ... 381 | }; 382 | ``` 383 | 384 | ### Input Validation 385 | 386 | Always validate instance context before processing: 387 | 388 | ```typescript 389 | import { validateInstanceContext } from 'n8n-mcp'; 390 | 391 | const validation = validateInstanceContext(context); 392 | if (!validation.valid) { 393 | throw new Error(`Invalid context: ${validation.errors?.join(', ')}`); 394 | } 395 | ``` 396 | 397 | ### Rate Limiting 398 | 399 | Implement rate limiting per tenant: 400 | 401 | ```typescript 402 | import rateLimit from 'express-rate-limit'; 403 | 404 | const limiter = rateLimit({ 405 | windowMs: 15 * 60 * 1000, // 15 minutes 406 | max: 100, // limit each IP to 100 requests per windowMs 407 | keyGenerator: (req) => req.user?.id || req.ip 408 | }); 409 | 410 | app.post('/api/instances/:instanceId/mcp', authenticate, limiter, async (req, res) => { 411 | // ... 412 | }); 413 | ``` 414 | 415 | ## Error Handling 416 | 417 | Always wrap MCP requests in try-catch blocks: 418 | 419 | ```typescript 420 | app.post('/api/instances/:instanceId/mcp', authenticate, async (req, res) => { 421 | try { 422 | const context = await getInstanceConfig(req.params.instanceId, req.user.id); 423 | await mcpEngine.processRequest(req, res, context); 424 | } catch (error) { 425 | console.error('MCP error:', error); 426 | 427 | // Don't leak internal errors to clients 428 | if (error.message.includes('not found')) { 429 | return res.status(404).json({ error: 'Instance not found' }); 430 | } 431 | 432 | res.status(500).json({ error: 'Internal server error' }); 433 | } 434 | }); 435 | ``` 436 | 437 | ## Monitoring 438 | 439 | ### Health Checks 440 | 441 | Set up periodic health checks: 442 | 443 | ```typescript 444 | setInterval(async () => { 445 | const health = await mcpEngine.healthCheck(); 446 | 447 | if (health.status === 'unhealthy') { 448 | console.error('MCP engine unhealthy:', health); 449 | // Alert your monitoring system 450 | } 451 | 452 | // Log metrics 453 | console.log('MCP engine metrics:', { 454 | uptime: health.uptime, 455 | memory: health.memoryUsage, 456 | sessionActive: health.sessionActive 457 | }); 458 | }, 60000); // Every minute 459 | ``` 460 | 461 | ### Session Monitoring 462 | 463 | Track active sessions: 464 | 465 | ```typescript 466 | app.get('/admin/sessions', authenticate, async (req, res) => { 467 | if (!req.user.isAdmin) { 468 | return res.status(403).json({ error: 'Forbidden' }); 469 | } 470 | 471 | const sessionInfo = mcpEngine.getSessionInfo(); 472 | res.json(sessionInfo); 473 | }); 474 | ``` 475 | 476 | ## Testing 477 | 478 | ### Unit Testing 479 | 480 | ```typescript 481 | import { N8NMCPEngine, InstanceContext } from 'n8n-mcp'; 482 | 483 | describe('MCP Engine', () => { 484 | let engine: N8NMCPEngine; 485 | 486 | beforeEach(() => { 487 | engine = new N8NMCPEngine({ logLevel: 'error' }); 488 | }); 489 | 490 | afterEach(async () => { 491 | await engine.shutdown(); 492 | }); 493 | 494 | it('should process request with context', async () => { 495 | const context: InstanceContext = { 496 | n8nApiUrl: 'https://test.n8n.io', 497 | n8nApiKey: 'test-key', 498 | instanceId: 'test-instance' 499 | }; 500 | 501 | const mockReq = createMockRequest(); 502 | const mockRes = createMockResponse(); 503 | 504 | await engine.processRequest(mockReq, mockRes, context); 505 | 506 | expect(mockRes.status).toBe(200); 507 | }); 508 | }); 509 | ``` 510 | 511 | ### Integration Testing 512 | 513 | ```typescript 514 | import request from 'supertest'; 515 | import { createApp } from './app'; 516 | 517 | describe('Multi-tenant MCP API', () => { 518 | let app; 519 | let authToken; 520 | 521 | beforeAll(async () => { 522 | app = await createApp(); 523 | authToken = await getTestAuthToken(); 524 | }); 525 | 526 | it('should handle MCP request for instance', async () => { 527 | const response = await request(app) 528 | .post('/api/instances/test-instance/mcp') 529 | .set('Authorization', `Bearer ${authToken}`) 530 | .send({ 531 | jsonrpc: '2.0', 532 | method: 'initialize', 533 | params: { 534 | protocolVersion: '2024-11-05', 535 | capabilities: {} 536 | }, 537 | id: 1 538 | }); 539 | 540 | expect(response.status).toBe(200); 541 | expect(response.body.result).toBeDefined(); 542 | }); 543 | }); 544 | ``` 545 | 546 | ## Deployment Considerations 547 | 548 | ### Environment Variables 549 | 550 | ```bash 551 | # Required for multi-tenant mode 552 | ENABLE_MULTI_TENANT=true 553 | MULTI_TENANT_SESSION_STRATEGY=instance 554 | 555 | # Optional: Logging 556 | LOG_LEVEL=info 557 | DISABLE_CONSOLE_OUTPUT=false 558 | 559 | # Optional: Session configuration 560 | SESSION_TIMEOUT=1800000 # 30 minutes in milliseconds 561 | MAX_SESSIONS=100 562 | 563 | # Optional: Performance 564 | NODE_ENV=production 565 | ``` 566 | 567 | ### Docker Deployment 568 | 569 | ```dockerfile 570 | FROM node:20-alpine 571 | 572 | WORKDIR /app 573 | 574 | COPY package*.json ./ 575 | RUN npm ci --only=production 576 | 577 | COPY . . 578 | 579 | ENV NODE_ENV=production 580 | ENV ENABLE_MULTI_TENANT=true 581 | ENV LOG_LEVEL=info 582 | 583 | EXPOSE 3000 584 | 585 | CMD ["node", "dist/server.js"] 586 | ``` 587 | 588 | ### Kubernetes Deployment 589 | 590 | ```yaml 591 | apiVersion: apps/v1 592 | kind: Deployment 593 | metadata: 594 | name: n8n-mcp-backend 595 | spec: 596 | replicas: 3 597 | selector: 598 | matchLabels: 599 | app: n8n-mcp-backend 600 | template: 601 | metadata: 602 | labels: 603 | app: n8n-mcp-backend 604 | spec: 605 | containers: 606 | - name: backend 607 | image: your-registry/n8n-mcp-backend:latest 608 | ports: 609 | - containerPort: 3000 610 | env: 611 | - name: ENABLE_MULTI_TENANT 612 | value: "true" 613 | - name: LOG_LEVEL 614 | value: "info" 615 | resources: 616 | requests: 617 | memory: "256Mi" 618 | cpu: "250m" 619 | limits: 620 | memory: "512Mi" 621 | cpu: "500m" 622 | livenessProbe: 623 | httpGet: 624 | path: /health 625 | port: 3000 626 | initialDelaySeconds: 10 627 | periodSeconds: 30 628 | readinessProbe: 629 | httpGet: 630 | path: /health 631 | port: 3000 632 | initialDelaySeconds: 5 633 | periodSeconds: 10 634 | ``` 635 | 636 | ## Examples 637 | 638 | ### Complete Multi-Tenant SaaS Example 639 | 640 | For a complete implementation example, see: 641 | - [n8n-mcp-backend](https://github.com/czlonkowski/n8n-mcp-backend) - Full hosted service implementation 642 | 643 | ### Migration from Single-Player 644 | 645 | If you're migrating from single-player (CLI/Docker) to multi-tenant: 646 | 647 | 1. **Keep backward compatibility** - Use environment fallback: 648 | ```typescript 649 | const context: InstanceContext = { 650 | n8nApiUrl: instanceUrl || process.env.N8N_API_URL, 651 | n8nApiKey: instanceKey || process.env.N8N_API_KEY, 652 | instanceId: instanceId || 'default' 653 | }; 654 | ``` 655 | 656 | 2. **Gradual rollout** - Start with a feature flag: 657 | ```typescript 658 | const isMultiTenant = process.env.ENABLE_MULTI_TENANT === 'true'; 659 | 660 | if (isMultiTenant) { 661 | const context = await getInstanceConfig(req.params.instanceId); 662 | await engine.processRequest(req, res, context); 663 | } else { 664 | // Legacy single-player mode 665 | await engine.processRequest(req, res); 666 | } 667 | ``` 668 | 669 | ## Troubleshooting 670 | 671 | ### Common Issues 672 | 673 | #### Module Resolution Errors 674 | 675 | If you see `Cannot find module 'n8n-mcp'`: 676 | 677 | ```bash 678 | # Clear node_modules and reinstall 679 | rm -rf node_modules package-lock.json 680 | npm install 681 | 682 | # Verify package has types field 683 | npm info n8n-mcp 684 | 685 | # Check TypeScript can resolve it 686 | npx tsc --noEmit 687 | ``` 688 | 689 | #### Session ID Validation Errors 690 | 691 | If you see `Invalid session ID format` errors: 692 | 693 | - Ensure you're using n8n-mcp v2.18.9 or later 694 | - Session IDs can be any non-empty string 695 | - No need to generate UUIDs - use your own format 696 | 697 | #### Memory Leaks 698 | 699 | If memory usage grows over time: 700 | 701 | ```typescript 702 | // Ensure proper cleanup 703 | process.on('SIGTERM', async () => { 704 | await engine.shutdown(); 705 | process.exit(0); 706 | }); 707 | 708 | // Monitor session count 709 | const sessionInfo = engine.getSessionInfo(); 710 | console.log('Active sessions:', sessionInfo.sessions?.active); 711 | ``` 712 | 713 | ## Further Reading 714 | 715 | - [MCP Protocol Specification](https://modelcontextprotocol.io/docs) 716 | - [n8n API Documentation](https://docs.n8n.io/api/) 717 | - [Express.js Guide](https://expressjs.com/en/guide/routing.html) 718 | - [n8n-mcp Main README](../README.md) 719 | 720 | ## Support 721 | 722 | - **Issues**: [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues) 723 | - **Discussions**: [GitHub Discussions](https://github.com/czlonkowski/n8n-mcp/discussions) 724 | - **Security**: For security issues, see [SECURITY.md](../SECURITY.md) 725 | ``` -------------------------------------------------------------------------------- /src/utils/node-source-extractor.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import { logger } from './logger'; 4 | 5 | export interface NodeSourceInfo { 6 | nodeType: string; 7 | sourceCode: string; 8 | credentialCode?: string; 9 | packageInfo?: any; 10 | location: string; 11 | } 12 | 13 | export class NodeSourceExtractor { 14 | private n8nBasePaths = [ 15 | '/usr/local/lib/node_modules/n8n/node_modules', 16 | '/app/node_modules', 17 | '/home/node/.n8n/custom/nodes', 18 | './node_modules', 19 | // Docker volume paths 20 | '/var/lib/docker/volumes/n8n-mcp_n8n_modules/_data', 21 | '/n8n-modules', 22 | // Common n8n installation paths 23 | process.env.N8N_CUSTOM_EXTENSIONS || '', 24 | // Additional local path for testing 25 | path.join(process.cwd(), 'node_modules'), 26 | ].filter(Boolean); 27 | 28 | /** 29 | * Extract source code for a specific n8n node 30 | */ 31 | async extractNodeSource(nodeType: string): Promise<NodeSourceInfo> { 32 | logger.info(`Extracting source code for node: ${nodeType}`); 33 | 34 | // Parse node type to get package and node name 35 | const { packageName, nodeName } = this.parseNodeType(nodeType); 36 | 37 | // Search for the node in known locations 38 | for (const basePath of this.n8nBasePaths) { 39 | try { 40 | const nodeInfo = await this.searchNodeInPath(basePath, packageName, nodeName); 41 | if (nodeInfo) { 42 | logger.info(`Found node source at: ${nodeInfo.location}`); 43 | return nodeInfo; 44 | } 45 | } catch (error) { 46 | logger.debug(`Failed to search in ${basePath}: ${error}`); 47 | } 48 | } 49 | 50 | throw new Error(`Node source code not found for: ${nodeType}`); 51 | } 52 | 53 | /** 54 | * Parse node type identifier 55 | */ 56 | private parseNodeType(nodeType: string): { packageName: string; nodeName: string } { 57 | // Handle different formats: 58 | // - @n8n/n8n-nodes-langchain.Agent 59 | // - n8n-nodes-base.HttpRequest 60 | // - customNode 61 | 62 | if (nodeType.includes('.')) { 63 | const [pkg, node] = nodeType.split('.'); 64 | return { packageName: pkg, nodeName: node }; 65 | } 66 | 67 | // Default to n8n-nodes-base for simple node names 68 | return { packageName: 'n8n-nodes-base', nodeName: nodeType }; 69 | } 70 | 71 | /** 72 | * Search for node in a specific path 73 | */ 74 | private async searchNodeInPath( 75 | basePath: string, 76 | packageName: string, 77 | nodeName: string 78 | ): Promise<NodeSourceInfo | null> { 79 | try { 80 | // Try both the provided case and capitalized first letter 81 | const nodeNameVariants = [ 82 | nodeName, 83 | nodeName.charAt(0).toUpperCase() + nodeName.slice(1), // Capitalize first letter 84 | nodeName.toLowerCase(), // All lowercase 85 | nodeName.toUpperCase(), // All uppercase 86 | ]; 87 | 88 | // First, try standard patterns with all case variants 89 | for (const nameVariant of nodeNameVariants) { 90 | const standardPatterns = [ 91 | `${packageName}/dist/nodes/${nameVariant}/${nameVariant}.node.js`, 92 | `${packageName}/dist/nodes/${nameVariant}.node.js`, 93 | `${packageName}/nodes/${nameVariant}/${nameVariant}.node.js`, 94 | `${packageName}/nodes/${nameVariant}.node.js`, 95 | `${nameVariant}/${nameVariant}.node.js`, 96 | `${nameVariant}.node.js`, 97 | ]; 98 | 99 | // Additional patterns for nested node structures (e.g., agents/Agent) 100 | const nestedPatterns = [ 101 | `${packageName}/dist/nodes/*/${nameVariant}/${nameVariant}.node.js`, 102 | `${packageName}/dist/nodes/**/${nameVariant}/${nameVariant}.node.js`, 103 | `${packageName}/nodes/*/${nameVariant}/${nameVariant}.node.js`, 104 | `${packageName}/nodes/**/${nameVariant}/${nameVariant}.node.js`, 105 | ]; 106 | 107 | // Try standard patterns first 108 | for (const pattern of standardPatterns) { 109 | const fullPath = path.join(basePath, pattern); 110 | const result = await this.tryLoadNodeFile(fullPath, packageName, nodeName, basePath); 111 | if (result) return result; 112 | } 113 | 114 | // Try nested patterns (with glob-like search) 115 | for (const pattern of nestedPatterns) { 116 | const result = await this.searchWithGlobPattern(basePath, pattern, packageName, nodeName); 117 | if (result) return result; 118 | } 119 | } 120 | 121 | // If basePath contains .pnpm, search in pnpm structure 122 | if (basePath.includes('node_modules')) { 123 | const pnpmPath = path.join(basePath, '.pnpm'); 124 | try { 125 | await fs.access(pnpmPath); 126 | const result = await this.searchInPnpm(pnpmPath, packageName, nodeName); 127 | if (result) return result; 128 | } catch { 129 | // .pnpm directory doesn't exist 130 | } 131 | } 132 | } catch (error) { 133 | logger.debug(`Error searching in path ${basePath}: ${error}`); 134 | } 135 | 136 | return null; 137 | } 138 | 139 | /** 140 | * Search for nodes in pnpm's special directory structure 141 | */ 142 | private async searchInPnpm( 143 | pnpmPath: string, 144 | packageName: string, 145 | nodeName: string 146 | ): Promise<NodeSourceInfo | null> { 147 | try { 148 | const entries = await fs.readdir(pnpmPath); 149 | 150 | // Filter entries that might contain our package 151 | const packageEntries = entries.filter(entry => 152 | entry.includes(packageName.replace('/', '+')) || 153 | entry.includes(packageName) 154 | ); 155 | 156 | for (const entry of packageEntries) { 157 | const entryPath = path.join(pnpmPath, entry, 'node_modules', packageName); 158 | 159 | // Search patterns within the pnpm package directory 160 | const patterns = [ 161 | `dist/nodes/${nodeName}/${nodeName}.node.js`, 162 | `dist/nodes/${nodeName}.node.js`, 163 | `dist/nodes/*/${nodeName}/${nodeName}.node.js`, 164 | `dist/nodes/**/${nodeName}/${nodeName}.node.js`, 165 | ]; 166 | 167 | for (const pattern of patterns) { 168 | if (pattern.includes('*')) { 169 | const result = await this.searchWithGlobPattern(entryPath, pattern, packageName, nodeName); 170 | if (result) return result; 171 | } else { 172 | const fullPath = path.join(entryPath, pattern); 173 | const result = await this.tryLoadNodeFile(fullPath, packageName, nodeName, entryPath); 174 | if (result) return result; 175 | } 176 | } 177 | } 178 | } catch (error) { 179 | logger.debug(`Error searching in pnpm directory: ${error}`); 180 | } 181 | 182 | return null; 183 | } 184 | 185 | /** 186 | * Search for files matching a glob-like pattern 187 | */ 188 | private async searchWithGlobPattern( 189 | basePath: string, 190 | pattern: string, 191 | packageName: string, 192 | nodeName: string 193 | ): Promise<NodeSourceInfo | null> { 194 | // Convert glob pattern to regex parts 195 | const parts = pattern.split('/'); 196 | const targetFile = `${nodeName}.node.js`; 197 | 198 | async function searchDir(currentPath: string, remainingParts: string[]): Promise<string | null> { 199 | if (remainingParts.length === 0) return null; 200 | 201 | const part = remainingParts[0]; 202 | const isLastPart = remainingParts.length === 1; 203 | 204 | try { 205 | if (isLastPart && part === targetFile) { 206 | // Check if file exists 207 | const fullPath = path.join(currentPath, part); 208 | await fs.access(fullPath); 209 | return fullPath; 210 | } 211 | 212 | const entries = await fs.readdir(currentPath, { withFileTypes: true }); 213 | 214 | for (const entry of entries) { 215 | if (!entry.isDirectory() && !isLastPart) continue; 216 | 217 | if (part === '*' || part === '**') { 218 | // Match any directory 219 | if (entry.isDirectory()) { 220 | const result = await searchDir( 221 | path.join(currentPath, entry.name), 222 | part === '**' ? remainingParts : remainingParts.slice(1) 223 | ); 224 | if (result) return result; 225 | } 226 | } else if (entry.name === part || (isLastPart && entry.name === targetFile)) { 227 | if (isLastPart && entry.isFile()) { 228 | return path.join(currentPath, entry.name); 229 | } else if (!isLastPart && entry.isDirectory()) { 230 | const result = await searchDir( 231 | path.join(currentPath, entry.name), 232 | remainingParts.slice(1) 233 | ); 234 | if (result) return result; 235 | } 236 | } 237 | } 238 | } catch { 239 | // Directory doesn't exist or can't be read 240 | } 241 | 242 | return null; 243 | } 244 | 245 | const foundPath = await searchDir(basePath, parts); 246 | if (foundPath) { 247 | return this.tryLoadNodeFile(foundPath, packageName, nodeName, basePath); 248 | } 249 | 250 | return null; 251 | } 252 | 253 | /** 254 | * Try to load a node file and its associated files 255 | */ 256 | private async tryLoadNodeFile( 257 | fullPath: string, 258 | packageName: string, 259 | nodeName: string, 260 | packageBasePath: string 261 | ): Promise<NodeSourceInfo | null> { 262 | try { 263 | const sourceCode = await fs.readFile(fullPath, 'utf-8'); 264 | 265 | // Try to find credential files 266 | let credentialCode: string | undefined; 267 | 268 | // First, try alongside the node file 269 | const credentialPath = fullPath.replace('.node.js', '.credentials.js'); 270 | try { 271 | credentialCode = await fs.readFile(credentialPath, 'utf-8'); 272 | } catch { 273 | // Try in the credentials directory 274 | const possibleCredentialPaths = [ 275 | // Standard n8n structure: dist/credentials/NodeNameApi.credentials.js 276 | path.join(packageBasePath, packageName, 'dist/credentials', `${nodeName}Api.credentials.js`), 277 | path.join(packageBasePath, packageName, 'dist/credentials', `${nodeName}OAuth2Api.credentials.js`), 278 | path.join(packageBasePath, packageName, 'credentials', `${nodeName}Api.credentials.js`), 279 | path.join(packageBasePath, packageName, 'credentials', `${nodeName}OAuth2Api.credentials.js`), 280 | // Without packageName in path 281 | path.join(packageBasePath, 'dist/credentials', `${nodeName}Api.credentials.js`), 282 | path.join(packageBasePath, 'dist/credentials', `${nodeName}OAuth2Api.credentials.js`), 283 | path.join(packageBasePath, 'credentials', `${nodeName}Api.credentials.js`), 284 | path.join(packageBasePath, 'credentials', `${nodeName}OAuth2Api.credentials.js`), 285 | // Try relative to node location 286 | path.join(path.dirname(path.dirname(fullPath)), 'credentials', `${nodeName}Api.credentials.js`), 287 | path.join(path.dirname(path.dirname(fullPath)), 'credentials', `${nodeName}OAuth2Api.credentials.js`), 288 | path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'credentials', `${nodeName}Api.credentials.js`), 289 | path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'credentials', `${nodeName}OAuth2Api.credentials.js`), 290 | ]; 291 | 292 | // Try to find any credential file 293 | const allCredentials: string[] = []; 294 | for (const credPath of possibleCredentialPaths) { 295 | try { 296 | const content = await fs.readFile(credPath, 'utf-8'); 297 | allCredentials.push(content); 298 | logger.debug(`Found credential file at: ${credPath}`); 299 | } catch { 300 | // Continue searching 301 | } 302 | } 303 | 304 | // If we found credentials, combine them 305 | if (allCredentials.length > 0) { 306 | credentialCode = allCredentials.join('\n\n// --- Next Credential File ---\n\n'); 307 | } 308 | } 309 | 310 | // Try to get package.json info 311 | let packageInfo: any; 312 | const possiblePackageJsonPaths = [ 313 | path.join(packageBasePath, 'package.json'), 314 | path.join(packageBasePath, packageName, 'package.json'), 315 | path.join(path.dirname(path.dirname(fullPath)), 'package.json'), 316 | path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'package.json'), 317 | // Try to go up from the node location to find package.json 318 | path.join(fullPath.split('/dist/')[0], 'package.json'), 319 | path.join(fullPath.split('/nodes/')[0], 'package.json'), 320 | ]; 321 | 322 | for (const packageJsonPath of possiblePackageJsonPaths) { 323 | try { 324 | const packageJson = await fs.readFile(packageJsonPath, 'utf-8'); 325 | packageInfo = JSON.parse(packageJson); 326 | logger.debug(`Found package.json at: ${packageJsonPath}`); 327 | break; 328 | } catch { 329 | // Try next path 330 | } 331 | } 332 | 333 | return { 334 | nodeType: `${packageName}.${nodeName}`, 335 | sourceCode, 336 | credentialCode, 337 | packageInfo, 338 | location: fullPath, 339 | }; 340 | } catch { 341 | return null; 342 | } 343 | } 344 | 345 | /** 346 | * List all available nodes 347 | */ 348 | async listAvailableNodes(category?: string, search?: string): Promise<any[]> { 349 | const nodes: any[] = []; 350 | const seenNodes = new Set<string>(); // Track unique nodes 351 | 352 | for (const basePath of this.n8nBasePaths) { 353 | try { 354 | // Check for n8n-nodes-base specifically 355 | const n8nNodesBasePath = path.join(basePath, 'n8n-nodes-base', 'dist', 'nodes'); 356 | try { 357 | await fs.access(n8nNodesBasePath); 358 | await this.scanDirectoryForNodes(n8nNodesBasePath, nodes, category, search, seenNodes); 359 | } catch { 360 | // Try without dist 361 | const altPath = path.join(basePath, 'n8n-nodes-base', 'nodes'); 362 | try { 363 | await fs.access(altPath); 364 | await this.scanDirectoryForNodes(altPath, nodes, category, search, seenNodes); 365 | } catch { 366 | // Try the base path directly 367 | await this.scanDirectoryForNodes(basePath, nodes, category, search, seenNodes); 368 | } 369 | } 370 | } catch (error) { 371 | logger.debug(`Failed to scan ${basePath}: ${error}`); 372 | } 373 | } 374 | 375 | return nodes; 376 | } 377 | 378 | /** 379 | * Scan directory for n8n nodes 380 | */ 381 | private async scanDirectoryForNodes( 382 | dirPath: string, 383 | nodes: any[], 384 | category?: string, 385 | search?: string, 386 | seenNodes?: Set<string> 387 | ): Promise<void> { 388 | try { 389 | const entries = await fs.readdir(dirPath, { withFileTypes: true }); 390 | 391 | for (const entry of entries) { 392 | if (entry.isFile() && entry.name.endsWith('.node.js')) { 393 | try { 394 | const fullPath = path.join(dirPath, entry.name); 395 | const content = await fs.readFile(fullPath, 'utf-8'); 396 | 397 | // Extract basic info from the source 398 | const nameMatch = content.match(/displayName:\s*['"`]([^'"`]+)['"`]/); 399 | const descriptionMatch = content.match(/description:\s*['"`]([^'"`]+)['"`]/); 400 | 401 | if (nameMatch) { 402 | const nodeName = entry.name.replace('.node.js', ''); 403 | 404 | // Skip if we've already seen this node 405 | if (seenNodes && seenNodes.has(nodeName)) { 406 | continue; 407 | } 408 | 409 | const nodeInfo = { 410 | name: nodeName, 411 | displayName: nameMatch[1], 412 | description: descriptionMatch ? descriptionMatch[1] : '', 413 | location: fullPath, 414 | }; 415 | 416 | // Apply filters 417 | if (category && !nodeInfo.displayName.toLowerCase().includes(category.toLowerCase())) { 418 | continue; 419 | } 420 | if (search && !nodeInfo.displayName.toLowerCase().includes(search.toLowerCase()) && 421 | !nodeInfo.description.toLowerCase().includes(search.toLowerCase())) { 422 | continue; 423 | } 424 | 425 | nodes.push(nodeInfo); 426 | if (seenNodes) { 427 | seenNodes.add(nodeName); 428 | } 429 | } 430 | } catch { 431 | // Skip files we can't read 432 | } 433 | } else if (entry.isDirectory()) { 434 | // Special handling for .pnpm directories 435 | if (entry.name === '.pnpm') { 436 | await this.scanPnpmDirectory(path.join(dirPath, entry.name), nodes, category, search, seenNodes); 437 | } else if (entry.name !== 'node_modules') { 438 | // Recursively scan subdirectories 439 | await this.scanDirectoryForNodes(path.join(dirPath, entry.name), nodes, category, search, seenNodes); 440 | } 441 | } 442 | } 443 | } catch (error) { 444 | logger.debug(`Error scanning directory ${dirPath}: ${error}`); 445 | } 446 | } 447 | 448 | /** 449 | * Scan pnpm directory structure for nodes 450 | */ 451 | private async scanPnpmDirectory( 452 | pnpmPath: string, 453 | nodes: any[], 454 | category?: string, 455 | search?: string, 456 | seenNodes?: Set<string> 457 | ): Promise<void> { 458 | try { 459 | const entries = await fs.readdir(pnpmPath); 460 | 461 | for (const entry of entries) { 462 | const entryPath = path.join(pnpmPath, entry, 'node_modules'); 463 | try { 464 | await fs.access(entryPath); 465 | await this.scanDirectoryForNodes(entryPath, nodes, category, search, seenNodes); 466 | } catch { 467 | // Skip if node_modules doesn't exist 468 | } 469 | } 470 | } catch (error) { 471 | logger.debug(`Error scanning pnpm directory ${pnpmPath}: ${error}`); 472 | } 473 | } 474 | 475 | /** 476 | * Extract AI Agent node specifically 477 | */ 478 | async extractAIAgentNode(): Promise<NodeSourceInfo> { 479 | // AI Agent is typically in @n8n/n8n-nodes-langchain package 480 | return this.extractNodeSource('@n8n/n8n-nodes-langchain.Agent'); 481 | } 482 | } ```