This is page 15 of 59. 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 │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── sqljs-memory-leak.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /docs/local/TEMPLATE_MINING_ANALYSIS.md: -------------------------------------------------------------------------------- ```markdown 1 | # Template Mining Analysis - Alternative to P0-R3 2 | 3 | **Date**: 2025-10-02 4 | **Context**: Analyzing whether to fix `get_node_for_task` (28% failure rate) or replace it with template-based configuration extraction 5 | 6 | ## Executive Summary 7 | 8 | **RECOMMENDATION**: Replace `get_node_for_task` with template-based configuration extraction. The template database contains 2,646 real-world workflows with rich node configurations that far exceed the 31 hardcoded task templates. 9 | 10 | ## Key Findings 11 | 12 | ### 1. Template Database Coverage 13 | 14 | - **Total Templates**: 2,646 production workflows from n8n.io 15 | - **Unique Node Types**: 543 (covers 103% of our 525 core nodes) 16 | - **Metadata Coverage**: 100% (AI-generated structured metadata) 17 | 18 | ### 2. Node Type Coverage in Templates 19 | 20 | Top node types by template usage: 21 | ``` 22 | 3,820 templates: n8n-nodes-base.httpRequest (144% of total templates!) 23 | 3,678 templates: n8n-nodes-base.set 24 | 2,445 templates: n8n-nodes-base.code 25 | 1,700 templates: n8n-nodes-base.googleSheets 26 | 1,471 templates: @n8n/n8n-nodes-langchain.agent 27 | 1,269 templates: @n8n/n8n-nodes-langchain.lmChatOpenAi 28 | 792 templates: n8n-nodes-base.telegram 29 | 702 templates: n8n-nodes-base.httpRequestTool 30 | 596 templates: n8n-nodes-base.gmail 31 | 466 templates: n8n-nodes-base.webhook 32 | ``` 33 | 34 | **Comparison**: 35 | - Hardcoded task templates: 31 tasks covering 5.9% of nodes 36 | - Real templates: 2,646 templates with 2-3k examples for common nodes 37 | 38 | ### 3. Database Structure 39 | 40 | ```sql 41 | CREATE TABLE templates ( 42 | id INTEGER PRIMARY KEY, 43 | workflow_id INTEGER UNIQUE NOT NULL, 44 | name TEXT NOT NULL, 45 | description TEXT, 46 | -- Node information 47 | nodes_used TEXT, -- JSON array: ["n8n-nodes-base.httpRequest", ...] 48 | workflow_json_compressed TEXT, -- Base64 encoded gzip of full workflow 49 | -- Metadata (100% coverage) 50 | metadata_json TEXT, -- AI-generated structured metadata 51 | -- Stats 52 | views INTEGER DEFAULT 0, 53 | created_at DATETIME, 54 | -- ... 55 | ); 56 | ``` 57 | 58 | ### 4. Real Configuration Examples 59 | 60 | #### HTTP Request Node Configurations 61 | 62 | **Simple URL fetch**: 63 | ```json 64 | { 65 | "url": "https://api.example.com/data", 66 | "options": {} 67 | } 68 | ``` 69 | 70 | **With authentication**: 71 | ```json 72 | { 73 | "url": "=https://api.wavespeed.ai/api/v3/predictions/{{ $json.data.id }}/result", 74 | "options": {}, 75 | "authentication": "genericCredentialType", 76 | "genericAuthType": "httpHeaderAuth" 77 | } 78 | ``` 79 | 80 | **Complex expressions**: 81 | ```json 82 | { 83 | "url": "=https://image.pollinations.ai/prompt/{{$('Social Media Content Factory').item.json.output.description.replaceAll(' ','-').replaceAll(',','').replaceAll('.','') }}", 84 | "options": {} 85 | } 86 | ``` 87 | 88 | #### Webhook Node Configurations 89 | 90 | **Basic webhook**: 91 | ```json 92 | { 93 | "path": "ytube", 94 | "options": {}, 95 | "httpMethod": "POST", 96 | "responseMode": "responseNode" 97 | } 98 | ``` 99 | 100 | **With binary data**: 101 | ```json 102 | { 103 | "path": "your-endpoint", 104 | "options": { 105 | "binaryPropertyName": "data" 106 | }, 107 | "httpMethod": "POST" 108 | } 109 | ``` 110 | 111 | ### 5. AI-Generated Metadata 112 | 113 | Each template has structured metadata including: 114 | 115 | ```json 116 | { 117 | "categories": ["automation", "integration", "data processing"], 118 | "complexity": "medium", 119 | "use_cases": [ 120 | "Extract transaction data from Gmail", 121 | "Automate bookkeeping", 122 | "Expense tracking" 123 | ], 124 | "estimated_setup_minutes": 30, 125 | "required_services": ["Gmail", "Google Sheets", "Google Gemini"], 126 | "key_features": [ 127 | "Fetch emails by label", 128 | "Extract transaction data", 129 | "Use LLM for structured output" 130 | ], 131 | "target_audience": ["Accountants", "Small business owners"] 132 | } 133 | ``` 134 | 135 | ## Comparison: Task Templates vs Real Templates 136 | 137 | ### Current Approach (get_node_for_task) 138 | 139 | **Pros**: 140 | - Curated configurations with best practices 141 | - Predictable, stable responses 142 | - Fast lookup (no decompression needed) 143 | 144 | **Cons**: 145 | - Only 31 tasks (5.9% node coverage) 146 | - 28% failure rate (users can't find what they need) 147 | - Requires manual maintenance 148 | - Static configurations without real-world context 149 | - Usage ratio 22.5:1 (search_nodes is preferred) 150 | 151 | ### Template-Based Approach 152 | 153 | **Pros**: 154 | - 2,646 real workflows with 2-3k examples for common nodes 155 | - 100% metadata coverage for semantic matching 156 | - Real-world patterns and best practices 157 | - Covers 543 node types (103% coverage) 158 | - Self-updating (templates fetched from n8n.io) 159 | - Rich context (use cases, complexity, setup time) 160 | 161 | **Cons**: 162 | - Requires decompression for full workflow access 163 | - May contain template-specific context (but can be filtered) 164 | - Need ranking/filtering logic for best matches 165 | 166 | ## Proposed Implementation Strategy 167 | 168 | ### Phase 1: Extract Node Configurations from Templates 169 | 170 | Create a new service: `TemplateConfigExtractor` 171 | 172 | ```typescript 173 | interface ExtractedNodeConfig { 174 | nodeType: string; 175 | configuration: Record<string, any>; 176 | source: { 177 | templateId: number; 178 | templateName: string; 179 | templateViews: number; 180 | useCases: string[]; 181 | complexity: 'simple' | 'medium' | 'complex'; 182 | }; 183 | patterns: { 184 | hasAuthentication: boolean; 185 | hasExpressions: boolean; 186 | hasOptionalFields: boolean; 187 | }; 188 | } 189 | 190 | class TemplateConfigExtractor { 191 | async extractConfigsForNode( 192 | nodeType: string, 193 | options?: { 194 | complexity?: 'simple' | 'medium' | 'complex'; 195 | requiresAuth?: boolean; 196 | limit?: number; 197 | } 198 | ): Promise<ExtractedNodeConfig[]> { 199 | // 1. Query templates containing nodeType 200 | // 2. Decompress workflow_json_compressed 201 | // 3. Extract node configurations 202 | // 4. Rank by popularity + complexity match 203 | // 5. Return top N configurations 204 | } 205 | } 206 | ``` 207 | 208 | ### Phase 2: Integrate with Existing Tools 209 | 210 | **Option A**: Enhance `get_node_essentials` 211 | - Add `includeExamples: boolean` parameter 212 | - Return 2-3 real configurations from templates 213 | - Preserve existing compact format 214 | 215 | **Option B**: Enhance `get_node_info` 216 | - Add `examples` section with template-sourced configs 217 | - Include source attribution (template name, views) 218 | 219 | **Option C**: New tool `get_node_examples` 220 | - Dedicated tool for retrieving configuration examples 221 | - Query by node type, complexity, use case 222 | - Returns ranked list of real configurations 223 | 224 | ### Phase 3: Deprecate get_node_for_task 225 | 226 | - Mark as deprecated in tool documentation 227 | - Redirect to enhanced tools 228 | - Remove after 2-3 version cycles 229 | 230 | ## Performance Considerations 231 | 232 | ### Decompression Cost 233 | 234 | - Average compressed size: 6-12 KB 235 | - Decompression time: ~5-10ms per template 236 | - Caching strategy needed for frequently accessed templates 237 | 238 | ### Query Strategy 239 | 240 | ```sql 241 | -- Fast: Get templates for a node type (no decompression) 242 | SELECT id, name, views, metadata_json 243 | FROM templates 244 | WHERE nodes_used LIKE '%n8n-nodes-base.httpRequest%' 245 | ORDER BY views DESC 246 | LIMIT 10; 247 | 248 | -- Then decompress only top matches 249 | ``` 250 | 251 | ### Caching 252 | 253 | - Cache decompressed workflows for popular templates (top 100) 254 | - TTL: 1 hour 255 | - Estimated memory: 100 * 50KB = 5MB 256 | 257 | ## Impact on P0-R3 258 | 259 | **Original P0-R3 Plan**: Expand task library from 31 to 100+ tasks using fuzzy matching 260 | 261 | **New Approach**: Mine 2,646 templates for real configurations 262 | 263 | **Impact Assessment**: 264 | 265 | | Metric | Original Plan | Template Mining | 266 | |--------|--------------|-----------------| 267 | | Configuration examples | 100 (estimated) | 2,646+ actual | 268 | | Node coverage | ~20% | 103% | 269 | | Maintenance | High (manual) | Low (auto-fetch) | 270 | | Accuracy | Curated | Production-tested | 271 | | Context richness | Limited | Rich metadata | 272 | | Development time | 2-3 weeks | 1 week | 273 | 274 | **Recommendation**: PIVOT to template mining approach for P0-R3 275 | 276 | ## Implementation Estimate 277 | 278 | ### Week 1: Core Infrastructure 279 | - Day 1-2: Create `TemplateConfigExtractor` service 280 | - Day 3: Implement caching layer 281 | - Day 4-5: Testing and optimization 282 | 283 | ### Week 2: Integration 284 | - Day 1-2: Enhance `get_node_essentials` with examples 285 | - Day 3: Update tool documentation 286 | - Day 4-5: Integration testing 287 | 288 | **Total**: 2 weeks vs 3 weeks for original plan 289 | 290 | ## Validation Tests 291 | 292 | ```typescript 293 | // Test: Extract HTTP Request configs 294 | const configs = await extractor.extractConfigsForNode( 295 | 'n8n-nodes-base.httpRequest', 296 | { complexity: 'simple', limit: 5 } 297 | ); 298 | 299 | // Expected: 5 configs from top templates 300 | // - Simple URL fetch 301 | // - With authentication 302 | // - With custom headers 303 | // - With expressions 304 | // - With error handling 305 | 306 | // Test: Extract webhook configs 307 | const webhookConfigs = await extractor.extractConfigsForNode( 308 | 'n8n-nodes-base.webhook', 309 | { limit: 3 } 310 | ); 311 | 312 | // Expected: 3 configs showing different patterns 313 | // - Basic POST webhook 314 | // - With response node 315 | // - With binary data handling 316 | ``` 317 | 318 | ## Risks and Mitigation 319 | 320 | ### Risk 1: Template Quality Varies 321 | - **Mitigation**: Filter by views (popularity) and metadata complexity rating 322 | - Only use templates with >1000 views for examples 323 | 324 | ### Risk 2: Decompression Performance 325 | - **Mitigation**: Cache decompressed popular templates 326 | - Implement lazy loading (decompress on demand) 327 | 328 | ### Risk 3: Template-Specific Context 329 | - **Mitigation**: Extract only node configuration, strip workflow-specific context 330 | - Provide source attribution for context 331 | 332 | ### Risk 4: Breaking Changes in Template Structure 333 | - **Mitigation**: Robust error handling in decompression 334 | - Fallback to cached configs if template fetch fails 335 | 336 | ## Success Metrics 337 | 338 | **Before** (get_node_for_task): 339 | - 392 calls, 72% success rate 340 | - 28% failure rate 341 | - 31 task templates 342 | - 5.9% node coverage 343 | 344 | **Target** (template-based): 345 | - 90%+ success rate for configuration discovery 346 | - 100%+ node coverage 347 | - 2,646+ real-world examples 348 | - Self-updating from n8n.io 349 | 350 | ## Next Steps 351 | 352 | 1. ✅ Complete template database analysis 353 | 2. ⏳ Create `TemplateConfigExtractor` service 354 | 3. ⏳ Implement caching layer 355 | 4. ⏳ Enhance `get_node_essentials` with examples 356 | 5. ⏳ Update P0 implementation plan 357 | 6. ⏳ Begin implementation 358 | 359 | ## Conclusion 360 | 361 | The template database provides a vastly superior alternative to hardcoded task templates: 362 | 363 | - **2,646 templates** vs 31 tasks (85x more examples) 364 | - **103% node coverage** vs 5.9% coverage (17x improvement) 365 | - **Real-world configurations** vs synthetic examples 366 | - **Self-updating** vs manual maintenance 367 | - **Rich metadata** for semantic matching 368 | 369 | **Recommendation**: Pivot P0-R3 from "expand task library" to "mine template configurations" 370 | ``` -------------------------------------------------------------------------------- /scripts/test-essentials.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env ts-node 2 | /** 3 | * Test script for validating the get_node_essentials tool 4 | * 5 | * This script: 6 | * 1. Compares get_node_essentials vs get_node_info response sizes 7 | * 2. Validates that essential properties are correctly extracted 8 | * 3. Checks that examples are properly generated 9 | * 4. Tests the property search functionality 10 | */ 11 | 12 | import { N8NDocumentationMCPServer } from '../src/mcp/server'; 13 | import { readFileSync, writeFileSync } from 'fs'; 14 | import { join } from 'path'; 15 | 16 | // Color codes for terminal output 17 | const colors = { 18 | reset: '\x1b[0m', 19 | bright: '\x1b[1m', 20 | green: '\x1b[32m', 21 | red: '\x1b[31m', 22 | yellow: '\x1b[33m', 23 | blue: '\x1b[34m', 24 | cyan: '\x1b[36m' 25 | }; 26 | 27 | function log(message: string, color: string = colors.reset) { 28 | console.log(`${color}${message}${colors.reset}`); 29 | } 30 | 31 | function logSection(title: string) { 32 | console.log('\n' + '='.repeat(60)); 33 | log(title, colors.bright + colors.cyan); 34 | console.log('='.repeat(60)); 35 | } 36 | 37 | function formatBytes(bytes: number): string { 38 | if (bytes < 1024) return bytes + ' B'; 39 | const kb = bytes / 1024; 40 | if (kb < 1024) return kb.toFixed(1) + ' KB'; 41 | const mb = kb / 1024; 42 | return mb.toFixed(2) + ' MB'; 43 | } 44 | 45 | async function testNodeEssentials(server: N8NDocumentationMCPServer, nodeType: string) { 46 | logSection(`Testing ${nodeType}`); 47 | 48 | try { 49 | // Get full node info 50 | const startFull = Date.now(); 51 | const fullInfo = await server.executeTool('get_node_info', { nodeType }); 52 | const fullTime = Date.now() - startFull; 53 | const fullSize = JSON.stringify(fullInfo).length; 54 | 55 | // Get essential info 56 | const startEssential = Date.now(); 57 | const essentialInfo = await server.executeTool('get_node_essentials', { nodeType }); 58 | const essentialTime = Date.now() - startEssential; 59 | const essentialSize = JSON.stringify(essentialInfo).length; 60 | 61 | // Calculate metrics 62 | const sizeReduction = ((fullSize - essentialSize) / fullSize * 100).toFixed(1); 63 | const speedImprovement = ((fullTime - essentialTime) / fullTime * 100).toFixed(1); 64 | 65 | // Display results 66 | log(`\n📊 Size Comparison:`, colors.bright); 67 | log(` Full response: ${formatBytes(fullSize)}`, colors.yellow); 68 | log(` Essential response: ${formatBytes(essentialSize)}`, colors.green); 69 | log(` Size reduction: ${sizeReduction}% ✨`, colors.bright + colors.green); 70 | 71 | log(`\n⚡ Performance:`, colors.bright); 72 | log(` Full response time: ${fullTime}ms`); 73 | log(` Essential response time: ${essentialTime}ms`); 74 | log(` Speed improvement: ${speedImprovement}%`, colors.green); 75 | 76 | log(`\n📋 Property Count:`, colors.bright); 77 | const fullPropCount = fullInfo.properties?.length || 0; 78 | const essentialPropCount = (essentialInfo.requiredProperties?.length || 0) + 79 | (essentialInfo.commonProperties?.length || 0); 80 | log(` Full properties: ${fullPropCount}`); 81 | log(` Essential properties: ${essentialPropCount}`); 82 | log(` Properties removed: ${fullPropCount - essentialPropCount} (${((fullPropCount - essentialPropCount) / fullPropCount * 100).toFixed(1)}%)`, colors.green); 83 | 84 | log(`\n🔧 Essential Properties:`, colors.bright); 85 | log(` Required: ${essentialInfo.requiredProperties?.map((p: any) => p.name).join(', ') || 'None'}`); 86 | log(` Common: ${essentialInfo.commonProperties?.map((p: any) => p.name).join(', ') || 'None'}`); 87 | 88 | log(`\n📚 Examples:`, colors.bright); 89 | const examples = Object.keys(essentialInfo.examples || {}); 90 | log(` Available examples: ${examples.join(', ') || 'None'}`); 91 | 92 | if (essentialInfo.examples?.minimal) { 93 | log(` Minimal example properties: ${Object.keys(essentialInfo.examples.minimal).join(', ')}`); 94 | } 95 | 96 | log(`\n📊 Metadata:`, colors.bright); 97 | log(` Total properties available: ${essentialInfo.metadata?.totalProperties || 0}`); 98 | log(` Is AI Tool: ${essentialInfo.metadata?.isAITool ? 'Yes' : 'No'}`); 99 | log(` Is Trigger: ${essentialInfo.metadata?.isTrigger ? 'Yes' : 'No'}`); 100 | log(` Has Credentials: ${essentialInfo.metadata?.hasCredentials ? 'Yes' : 'No'}`); 101 | 102 | // Test property search 103 | const searchTerms = ['auth', 'header', 'body', 'json']; 104 | log(`\n🔍 Property Search Test:`, colors.bright); 105 | 106 | for (const term of searchTerms) { 107 | try { 108 | const searchResult = await server.executeTool('search_node_properties', { 109 | nodeType, 110 | query: term, 111 | maxResults: 5 112 | }); 113 | log(` "${term}": Found ${searchResult.totalMatches} properties`); 114 | } catch (error) { 115 | log(` "${term}": Search failed`, colors.red); 116 | } 117 | } 118 | 119 | return { 120 | nodeType, 121 | fullSize, 122 | essentialSize, 123 | sizeReduction: parseFloat(sizeReduction), 124 | fullPropCount, 125 | essentialPropCount, 126 | success: true 127 | }; 128 | 129 | } catch (error) { 130 | log(`❌ Error testing ${nodeType}: ${error}`, colors.red); 131 | return { 132 | nodeType, 133 | fullSize: 0, 134 | essentialSize: 0, 135 | sizeReduction: 0, 136 | fullPropCount: 0, 137 | essentialPropCount: 0, 138 | success: false, 139 | error: error instanceof Error ? error.message : String(error) 140 | }; 141 | } 142 | } 143 | 144 | async function main() { 145 | logSection('n8n MCP Essentials Tool Test Suite'); 146 | 147 | try { 148 | // Initialize server 149 | log('\n🚀 Initializing MCP server...', colors.cyan); 150 | const server = new N8NDocumentationMCPServer(); 151 | 152 | // Wait for initialization 153 | await new Promise(resolve => setTimeout(resolve, 1000)); 154 | 155 | // Test nodes 156 | const testNodes = [ 157 | 'nodes-base.httpRequest', 158 | 'nodes-base.webhook', 159 | 'nodes-base.code', 160 | 'nodes-base.set', 161 | 'nodes-base.if', 162 | 'nodes-base.postgres', 163 | 'nodes-base.openAi', 164 | 'nodes-base.googleSheets', 165 | 'nodes-base.slack', 166 | 'nodes-base.merge' 167 | ]; 168 | 169 | const results = []; 170 | 171 | for (const nodeType of testNodes) { 172 | const result = await testNodeEssentials(server, nodeType); 173 | results.push(result); 174 | } 175 | 176 | // Summary 177 | logSection('Test Summary'); 178 | 179 | const successful = results.filter(r => r.success); 180 | const totalFullSize = successful.reduce((sum, r) => sum + r.fullSize, 0); 181 | const totalEssentialSize = successful.reduce((sum, r) => sum + r.essentialSize, 0); 182 | const avgReduction = successful.reduce((sum, r) => sum + r.sizeReduction, 0) / successful.length; 183 | 184 | log(`\n✅ Successful tests: ${successful.length}/${results.length}`, colors.green); 185 | 186 | if (successful.length > 0) { 187 | log(`\n📊 Overall Statistics:`, colors.bright); 188 | log(` Total full size: ${formatBytes(totalFullSize)}`); 189 | log(` Total essential size: ${formatBytes(totalEssentialSize)}`); 190 | log(` Average reduction: ${avgReduction.toFixed(1)}%`, colors.bright + colors.green); 191 | 192 | log(`\n🏆 Best Performers:`, colors.bright); 193 | const sorted = successful.sort((a, b) => b.sizeReduction - a.sizeReduction); 194 | sorted.slice(0, 3).forEach((r, i) => { 195 | log(` ${i + 1}. ${r.nodeType}: ${r.sizeReduction}% reduction (${formatBytes(r.fullSize)} → ${formatBytes(r.essentialSize)})`); 196 | }); 197 | } 198 | 199 | const failed = results.filter(r => !r.success); 200 | if (failed.length > 0) { 201 | log(`\n❌ Failed tests:`, colors.red); 202 | failed.forEach(r => { 203 | log(` - ${r.nodeType}: ${r.error}`, colors.red); 204 | }); 205 | } 206 | 207 | // Save detailed results 208 | const reportPath = join(process.cwd(), 'test-results-essentials.json'); 209 | writeFileSync(reportPath, JSON.stringify({ 210 | timestamp: new Date().toISOString(), 211 | summary: { 212 | totalTests: results.length, 213 | successful: successful.length, 214 | failed: failed.length, 215 | averageReduction: avgReduction, 216 | totalFullSize, 217 | totalEssentialSize 218 | }, 219 | results 220 | }, null, 2)); 221 | 222 | log(`\n📄 Detailed results saved to: ${reportPath}`, colors.cyan); 223 | 224 | // Recommendations 225 | logSection('Recommendations'); 226 | 227 | if (avgReduction > 90) { 228 | log('✨ Excellent! The essentials tool is achieving >90% size reduction.', colors.green); 229 | } else if (avgReduction > 80) { 230 | log('👍 Good! The essentials tool is achieving 80-90% size reduction.', colors.yellow); 231 | log(' Consider reviewing nodes with lower reduction rates.'); 232 | } else { 233 | log('⚠️ The average size reduction is below 80%.', colors.yellow); 234 | log(' Review the essential property lists for optimization.'); 235 | } 236 | 237 | // Test specific functionality 238 | logSection('Testing Advanced Features'); 239 | 240 | // Test error handling 241 | log('\n🧪 Testing error handling...', colors.cyan); 242 | try { 243 | await server.executeTool('get_node_essentials', { nodeType: 'non-existent-node' }); 244 | log(' ❌ Error handling failed - should have thrown error', colors.red); 245 | } catch (error) { 246 | log(' ✅ Error handling works correctly', colors.green); 247 | } 248 | 249 | // Test alternative node type formats 250 | log('\n🧪 Testing alternative node type formats...', colors.cyan); 251 | const alternativeFormats = [ 252 | { input: 'httpRequest', expected: 'nodes-base.httpRequest' }, 253 | { input: 'nodes-base.httpRequest', expected: 'nodes-base.httpRequest' }, 254 | { input: 'HTTPREQUEST', expected: 'nodes-base.httpRequest' } 255 | ]; 256 | 257 | for (const format of alternativeFormats) { 258 | try { 259 | const result = await server.executeTool('get_node_essentials', { nodeType: format.input }); 260 | if (result.nodeType === format.expected) { 261 | log(` ✅ "${format.input}" → "${format.expected}"`, colors.green); 262 | } else { 263 | log(` ❌ "${format.input}" → "${result.nodeType}" (expected "${format.expected}")`, colors.red); 264 | } 265 | } catch (error) { 266 | log(` ❌ "${format.input}" → Error: ${error}`, colors.red); 267 | } 268 | } 269 | 270 | log('\n✨ Test suite completed!', colors.bright + colors.green); 271 | 272 | } catch (error) { 273 | log(`\n❌ Fatal error: ${error}`, colors.red); 274 | process.exit(1); 275 | } 276 | } 277 | 278 | // Run the test 279 | main().catch(error => { 280 | console.error('Unhandled error:', error); 281 | process.exit(1); 282 | }); ``` -------------------------------------------------------------------------------- /src/services/expression-validator.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Expression Validator for n8n expressions 3 | * Validates expression syntax, variable references, and context availability 4 | */ 5 | 6 | interface ExpressionValidationResult { 7 | valid: boolean; 8 | errors: string[]; 9 | warnings: string[]; 10 | usedVariables: Set<string>; 11 | usedNodes: Set<string>; 12 | } 13 | 14 | interface ExpressionContext { 15 | availableNodes: string[]; 16 | currentNodeName?: string; 17 | isInLoop?: boolean; 18 | hasInputData?: boolean; 19 | } 20 | 21 | export class ExpressionValidator { 22 | // Common n8n expression patterns 23 | private static readonly EXPRESSION_PATTERN = /\{\{([\s\S]+?)\}\}/g; 24 | private static readonly VARIABLE_PATTERNS = { 25 | json: /\$json(\.[a-zA-Z_][\w]*|\["[^"]+"\]|\['[^']+'\]|\[\d+\])*/g, 26 | node: /\$node\["([^"]+)"\]\.json/g, 27 | input: /\$input\.item(\.[a-zA-Z_][\w]*|\["[^"]+"\]|\['[^']+'\]|\[\d+\])*/g, 28 | items: /\$items\("([^"]+)"(?:,\s*(-?\d+))?\)/g, 29 | parameter: /\$parameter\["([^"]+)"\]/g, 30 | env: /\$env\.([a-zA-Z_][\w]*)/g, 31 | workflow: /\$workflow\.(id|name|active)/g, 32 | execution: /\$execution\.(id|mode|resumeUrl)/g, 33 | prevNode: /\$prevNode\.(name|outputIndex|runIndex)/g, 34 | itemIndex: /\$itemIndex/g, 35 | runIndex: /\$runIndex/g, 36 | now: /\$now/g, 37 | today: /\$today/g, 38 | }; 39 | 40 | /** 41 | * Validate a single expression 42 | */ 43 | static validateExpression( 44 | expression: string, 45 | context: ExpressionContext 46 | ): ExpressionValidationResult { 47 | const result: ExpressionValidationResult = { 48 | valid: true, 49 | errors: [], 50 | warnings: [], 51 | usedVariables: new Set(), 52 | usedNodes: new Set(), 53 | }; 54 | 55 | // Handle null/undefined expression 56 | if (!expression) { 57 | return result; 58 | } 59 | 60 | // Handle null/undefined context 61 | if (!context) { 62 | result.valid = false; 63 | result.errors.push('Validation context is required'); 64 | return result; 65 | } 66 | 67 | // Check for basic syntax errors 68 | const syntaxErrors = this.checkSyntaxErrors(expression); 69 | result.errors.push(...syntaxErrors); 70 | 71 | // Extract all expressions 72 | const expressions = this.extractExpressions(expression); 73 | 74 | for (const expr of expressions) { 75 | // Validate each expression 76 | this.validateSingleExpression(expr, context, result); 77 | } 78 | 79 | // Check for undefined node references 80 | this.checkNodeReferences(result, context); 81 | 82 | result.valid = result.errors.length === 0; 83 | return result; 84 | } 85 | 86 | /** 87 | * Check for basic syntax errors 88 | */ 89 | private static checkSyntaxErrors(expression: string): string[] { 90 | const errors: string[] = []; 91 | 92 | // Check for unmatched brackets 93 | const openBrackets = (expression.match(/\{\{/g) || []).length; 94 | const closeBrackets = (expression.match(/\}\}/g) || []).length; 95 | 96 | if (openBrackets !== closeBrackets) { 97 | errors.push('Unmatched expression brackets {{ }}'); 98 | } 99 | 100 | // Check for nested expressions (not supported in n8n) 101 | if (expression.includes('{{') && expression.includes('{{', expression.indexOf('{{') + 2)) { 102 | const match = expression.match(/\{\{.*\{\{/); 103 | if (match) { 104 | errors.push('Nested expressions are not supported'); 105 | } 106 | } 107 | 108 | // Check for empty expressions 109 | const emptyExpressionPattern = /\{\{\s*\}\}/; 110 | if (emptyExpressionPattern.test(expression)) { 111 | errors.push('Empty expression found'); 112 | } 113 | 114 | return errors; 115 | } 116 | 117 | /** 118 | * Extract all expressions from a string 119 | */ 120 | private static extractExpressions(text: string): string[] { 121 | const expressions: string[] = []; 122 | let match; 123 | 124 | while ((match = this.EXPRESSION_PATTERN.exec(text)) !== null) { 125 | expressions.push(match[1].trim()); 126 | } 127 | 128 | return expressions; 129 | } 130 | 131 | /** 132 | * Validate a single expression content 133 | */ 134 | private static validateSingleExpression( 135 | expr: string, 136 | context: ExpressionContext, 137 | result: ExpressionValidationResult 138 | ): void { 139 | // Check for $json usage 140 | let match; 141 | const jsonPattern = new RegExp(this.VARIABLE_PATTERNS.json.source, this.VARIABLE_PATTERNS.json.flags); 142 | while ((match = jsonPattern.exec(expr)) !== null) { 143 | result.usedVariables.add('$json'); 144 | 145 | if (!context.hasInputData && !context.isInLoop) { 146 | result.warnings.push( 147 | 'Using $json but node might not have input data' 148 | ); 149 | } 150 | 151 | // Check for suspicious property names that might be test/invalid data 152 | const fullMatch = match[0]; 153 | if (fullMatch.includes('.invalid') || fullMatch.includes('.undefined') || 154 | fullMatch.includes('.null') || fullMatch.includes('.test')) { 155 | result.warnings.push( 156 | `Property access '${fullMatch}' looks suspicious - verify this property exists in your data` 157 | ); 158 | } 159 | } 160 | 161 | // Check for $node references 162 | const nodePattern = new RegExp(this.VARIABLE_PATTERNS.node.source, this.VARIABLE_PATTERNS.node.flags); 163 | while ((match = nodePattern.exec(expr)) !== null) { 164 | const nodeName = match[1]; 165 | result.usedNodes.add(nodeName); 166 | result.usedVariables.add('$node'); 167 | } 168 | 169 | // Check for $input usage 170 | const inputPattern = new RegExp(this.VARIABLE_PATTERNS.input.source, this.VARIABLE_PATTERNS.input.flags); 171 | while ((match = inputPattern.exec(expr)) !== null) { 172 | result.usedVariables.add('$input'); 173 | 174 | if (!context.hasInputData) { 175 | result.warnings.push( 176 | '$input is only available when the node has input data' 177 | ); 178 | } 179 | } 180 | 181 | // Check for $items usage 182 | const itemsPattern = new RegExp(this.VARIABLE_PATTERNS.items.source, this.VARIABLE_PATTERNS.items.flags); 183 | while ((match = itemsPattern.exec(expr)) !== null) { 184 | const nodeName = match[1]; 185 | result.usedNodes.add(nodeName); 186 | result.usedVariables.add('$items'); 187 | } 188 | 189 | // Check for other variables 190 | for (const [varName, pattern] of Object.entries(this.VARIABLE_PATTERNS)) { 191 | if (['json', 'node', 'input', 'items'].includes(varName)) continue; 192 | 193 | const testPattern = new RegExp(pattern.source, pattern.flags); 194 | if (testPattern.test(expr)) { 195 | result.usedVariables.add(`$${varName}`); 196 | } 197 | } 198 | 199 | // Check for common mistakes 200 | this.checkCommonMistakes(expr, result); 201 | } 202 | 203 | /** 204 | * Check for common expression mistakes 205 | */ 206 | private static checkCommonMistakes( 207 | expr: string, 208 | result: ExpressionValidationResult 209 | ): void { 210 | // Check for missing $ prefix - but exclude cases where $ is already present 211 | const missingPrefixPattern = /(?<!\$)\b(json|node|input|items|workflow|execution)\b(?!\s*:)/; 212 | if (expr.match(missingPrefixPattern)) { 213 | result.warnings.push( 214 | 'Possible missing $ prefix for variable (e.g., use $json instead of json)' 215 | ); 216 | } 217 | 218 | // Check for incorrect array access 219 | if (expr.includes('$json[') && !expr.match(/\$json\[\d+\]/)) { 220 | result.warnings.push( 221 | 'Array access should use numeric index: $json[0] or property access: $json.property' 222 | ); 223 | } 224 | 225 | // Check for Python-style property access 226 | if (expr.match(/\$json\['[^']+'\]/)) { 227 | result.warnings.push( 228 | "Consider using dot notation: $json.property instead of $json['property']" 229 | ); 230 | } 231 | 232 | // Check for undefined/null access attempts 233 | if (expr.match(/\?\./)) { 234 | result.warnings.push( 235 | 'Optional chaining (?.) is not supported in n8n expressions' 236 | ); 237 | } 238 | 239 | // Check for template literals 240 | if (expr.includes('${')) { 241 | result.errors.push( 242 | 'Template literals ${} are not supported. Use string concatenation instead' 243 | ); 244 | } 245 | } 246 | 247 | /** 248 | * Check that all referenced nodes exist 249 | */ 250 | private static checkNodeReferences( 251 | result: ExpressionValidationResult, 252 | context: ExpressionContext 253 | ): void { 254 | for (const nodeName of result.usedNodes) { 255 | if (!context.availableNodes.includes(nodeName)) { 256 | result.errors.push( 257 | `Referenced node "${nodeName}" not found in workflow` 258 | ); 259 | } 260 | } 261 | } 262 | 263 | /** 264 | * Validate all expressions in a node's parameters 265 | */ 266 | static validateNodeExpressions( 267 | parameters: any, 268 | context: ExpressionContext 269 | ): ExpressionValidationResult { 270 | const combinedResult: ExpressionValidationResult = { 271 | valid: true, 272 | errors: [], 273 | warnings: [], 274 | usedVariables: new Set(), 275 | usedNodes: new Set(), 276 | }; 277 | 278 | const visited = new WeakSet(); 279 | this.validateParametersRecursive(parameters, context, combinedResult, '', visited); 280 | 281 | combinedResult.valid = combinedResult.errors.length === 0; 282 | return combinedResult; 283 | } 284 | 285 | /** 286 | * Recursively validate expressions in parameters 287 | */ 288 | private static validateParametersRecursive( 289 | obj: any, 290 | context: ExpressionContext, 291 | result: ExpressionValidationResult, 292 | path: string = '', 293 | visited: WeakSet<object> = new WeakSet() 294 | ): void { 295 | // Handle circular references 296 | if (obj && typeof obj === 'object') { 297 | if (visited.has(obj)) { 298 | return; // Skip already visited objects 299 | } 300 | visited.add(obj); 301 | } 302 | 303 | if (typeof obj === 'string') { 304 | if (obj.includes('{{')) { 305 | const validation = this.validateExpression(obj, context); 306 | 307 | // Add path context to errors 308 | validation.errors.forEach(error => { 309 | result.errors.push(path ? `${path}: ${error}` : error); 310 | }); 311 | 312 | validation.warnings.forEach(warning => { 313 | result.warnings.push(path ? `${path}: ${warning}` : warning); 314 | }); 315 | 316 | // Merge used variables and nodes 317 | validation.usedVariables.forEach(v => result.usedVariables.add(v)); 318 | validation.usedNodes.forEach(n => result.usedNodes.add(n)); 319 | } 320 | } else if (Array.isArray(obj)) { 321 | obj.forEach((item, index) => { 322 | this.validateParametersRecursive( 323 | item, 324 | context, 325 | result, 326 | `${path}[${index}]`, 327 | visited 328 | ); 329 | }); 330 | } else if (obj && typeof obj === 'object') { 331 | Object.entries(obj).forEach(([key, value]) => { 332 | const newPath = path ? `${path}.${key}` : key; 333 | this.validateParametersRecursive(value, context, result, newPath, visited); 334 | }); 335 | } 336 | } 337 | } ``` -------------------------------------------------------------------------------- /src/mcp/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { N8NDocumentationMCPServer } from './server'; 4 | import { logger } from '../utils/logger'; 5 | import { TelemetryConfigManager } from '../telemetry/config-manager'; 6 | import { EarlyErrorLogger } from '../telemetry/early-error-logger'; 7 | import { STARTUP_CHECKPOINTS, findFailedCheckpoint, StartupCheckpoint } from '../telemetry/startup-checkpoints'; 8 | import { existsSync } from 'fs'; 9 | 10 | // Add error details to stderr for Claude Desktop debugging 11 | process.on('uncaughtException', (error) => { 12 | if (process.env.MCP_MODE !== 'stdio') { 13 | console.error('Uncaught Exception:', error); 14 | } 15 | logger.error('Uncaught Exception:', error); 16 | process.exit(1); 17 | }); 18 | 19 | process.on('unhandledRejection', (reason, promise) => { 20 | if (process.env.MCP_MODE !== 'stdio') { 21 | console.error('Unhandled Rejection at:', promise, 'reason:', reason); 22 | } 23 | logger.error('Unhandled Rejection:', reason); 24 | process.exit(1); 25 | }); 26 | 27 | /** 28 | * Detects if running in a container environment (Docker, Podman, Kubernetes, etc.) 29 | * Uses multiple detection methods for robustness: 30 | * 1. Environment variables (IS_DOCKER, IS_CONTAINER with multiple formats) 31 | * 2. Filesystem markers (/.dockerenv, /run/.containerenv) 32 | */ 33 | function isContainerEnvironment(): boolean { 34 | // Check environment variables with multiple truthy formats 35 | const dockerEnv = (process.env.IS_DOCKER || '').toLowerCase(); 36 | const containerEnv = (process.env.IS_CONTAINER || '').toLowerCase(); 37 | 38 | if (['true', '1', 'yes'].includes(dockerEnv)) { 39 | return true; 40 | } 41 | if (['true', '1', 'yes'].includes(containerEnv)) { 42 | return true; 43 | } 44 | 45 | // Fallback: Check filesystem markers 46 | // /.dockerenv exists in Docker containers 47 | // /run/.containerenv exists in Podman containers 48 | try { 49 | return existsSync('/.dockerenv') || existsSync('/run/.containerenv'); 50 | } catch (error) { 51 | // If filesystem check fails, assume not in container 52 | logger.debug('Container detection filesystem check failed:', error); 53 | return false; 54 | } 55 | } 56 | 57 | async function main() { 58 | // Initialize early error logger for pre-handshake error capture (v2.18.3) 59 | // Now using singleton pattern with defensive initialization 60 | const startTime = Date.now(); 61 | const earlyLogger = EarlyErrorLogger.getInstance(); 62 | const checkpoints: StartupCheckpoint[] = []; 63 | 64 | try { 65 | // Checkpoint: Process started (fire-and-forget, no await) 66 | earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED); 67 | checkpoints.push(STARTUP_CHECKPOINTS.PROCESS_STARTED); 68 | 69 | // Handle telemetry CLI commands 70 | const args = process.argv.slice(2); 71 | if (args.length > 0 && args[0] === 'telemetry') { 72 | const telemetryConfig = TelemetryConfigManager.getInstance(); 73 | const action = args[1]; 74 | 75 | switch (action) { 76 | case 'enable': 77 | telemetryConfig.enable(); 78 | process.exit(0); 79 | break; 80 | case 'disable': 81 | telemetryConfig.disable(); 82 | process.exit(0); 83 | break; 84 | case 'status': 85 | console.log(telemetryConfig.getStatus()); 86 | process.exit(0); 87 | break; 88 | default: 89 | console.log(` 90 | Usage: n8n-mcp telemetry [command] 91 | 92 | Commands: 93 | enable Enable anonymous telemetry 94 | disable Disable anonymous telemetry 95 | status Show current telemetry status 96 | 97 | Learn more: https://github.com/czlonkowski/n8n-mcp/blob/main/PRIVACY.md 98 | `); 99 | process.exit(args[1] ? 1 : 0); 100 | } 101 | } 102 | 103 | const mode = process.env.MCP_MODE || 'stdio'; 104 | 105 | // Checkpoint: Telemetry initializing (fire-and-forget, no await) 106 | earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.TELEMETRY_INITIALIZING); 107 | checkpoints.push(STARTUP_CHECKPOINTS.TELEMETRY_INITIALIZING); 108 | 109 | // Telemetry is already initialized by TelemetryConfigManager in imports 110 | // Mark as ready (fire-and-forget, no await) 111 | earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.TELEMETRY_READY); 112 | checkpoints.push(STARTUP_CHECKPOINTS.TELEMETRY_READY); 113 | 114 | try { 115 | // Only show debug messages in HTTP mode to avoid corrupting stdio communication 116 | if (mode === 'http') { 117 | console.error(`Starting n8n Documentation MCP Server in ${mode} mode...`); 118 | console.error('Current directory:', process.cwd()); 119 | console.error('Node version:', process.version); 120 | } 121 | 122 | // Checkpoint: MCP handshake starting (fire-and-forget, no await) 123 | earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_STARTING); 124 | checkpoints.push(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_STARTING); 125 | 126 | if (mode === 'http') { 127 | // Check if we should use the fixed implementation 128 | if (process.env.USE_FIXED_HTTP === 'true') { 129 | // Use the fixed HTTP implementation that bypasses StreamableHTTPServerTransport issues 130 | const { startFixedHTTPServer } = await import('../http-server'); 131 | await startFixedHTTPServer(); 132 | } else { 133 | // HTTP mode - for remote deployment with single-session architecture 134 | const { SingleSessionHTTPServer } = await import('../http-server-single-session'); 135 | const server = new SingleSessionHTTPServer(); 136 | 137 | // Graceful shutdown handlers 138 | const shutdown = async () => { 139 | await server.shutdown(); 140 | process.exit(0); 141 | }; 142 | 143 | process.on('SIGTERM', shutdown); 144 | process.on('SIGINT', shutdown); 145 | 146 | await server.start(); 147 | } 148 | } else { 149 | // Stdio mode - for local Claude Desktop 150 | const server = new N8NDocumentationMCPServer(undefined, earlyLogger); 151 | 152 | // Graceful shutdown handler (fixes Issue #277) 153 | let isShuttingDown = false; 154 | const shutdown = async (signal: string = 'UNKNOWN') => { 155 | if (isShuttingDown) return; // Prevent multiple shutdown calls 156 | isShuttingDown = true; 157 | 158 | try { 159 | logger.info(`Shutdown initiated by: ${signal}`); 160 | 161 | await server.shutdown(); 162 | 163 | // Close stdin to signal we're done reading 164 | if (process.stdin && !process.stdin.destroyed) { 165 | process.stdin.pause(); 166 | process.stdin.destroy(); 167 | } 168 | 169 | // Exit with timeout to ensure we don't hang 170 | // Increased to 1000ms for slower systems 171 | setTimeout(() => { 172 | logger.warn('Shutdown timeout exceeded, forcing exit'); 173 | process.exit(0); 174 | }, 1000).unref(); 175 | 176 | // Let the timeout handle the exit for graceful shutdown 177 | // (removed immediate exit to allow cleanup to complete) 178 | } catch (error) { 179 | logger.error('Error during shutdown:', error); 180 | process.exit(1); 181 | } 182 | }; 183 | 184 | // Handle termination signals (fixes Issue #277) 185 | // Signal handling strategy: 186 | // - Claude Desktop (Windows/macOS/Linux): stdin handlers + signal handlers 187 | // Primary: stdin close when Claude quits | Fallback: SIGTERM/SIGINT/SIGHUP 188 | // - Container environments: signal handlers ONLY 189 | // stdin closed in detached mode would trigger immediate shutdown 190 | // Container detection via IS_DOCKER/IS_CONTAINER env vars + filesystem markers 191 | // - Manual execution: Both stdin and signal handlers work 192 | process.on('SIGTERM', () => shutdown('SIGTERM')); 193 | process.on('SIGINT', () => shutdown('SIGINT')); 194 | process.on('SIGHUP', () => shutdown('SIGHUP')); 195 | 196 | // Handle stdio disconnect - PRIMARY shutdown mechanism for Claude Desktop 197 | // Skip in container environments (Docker, Kubernetes, Podman) to prevent 198 | // premature shutdown when stdin is closed in detached mode. 199 | // Containers rely on signal handlers (SIGTERM/SIGINT/SIGHUP) for proper shutdown. 200 | const isContainer = isContainerEnvironment(); 201 | 202 | if (!isContainer && process.stdin.readable && !process.stdin.destroyed) { 203 | try { 204 | process.stdin.on('end', () => shutdown('STDIN_END')); 205 | process.stdin.on('close', () => shutdown('STDIN_CLOSE')); 206 | } catch (error) { 207 | logger.error('Failed to register stdin handlers, using signal handlers only:', error); 208 | // Continue - signal handlers will still work 209 | } 210 | } 211 | 212 | await server.run(); 213 | } 214 | 215 | // Checkpoint: MCP handshake complete (fire-and-forget, no await) 216 | earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_COMPLETE); 217 | checkpoints.push(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_COMPLETE); 218 | 219 | // Checkpoint: Server ready (fire-and-forget, no await) 220 | earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.SERVER_READY); 221 | checkpoints.push(STARTUP_CHECKPOINTS.SERVER_READY); 222 | 223 | // Log successful startup (fire-and-forget, no await) 224 | const startupDuration = Date.now() - startTime; 225 | earlyLogger.logStartupSuccess(checkpoints, startupDuration); 226 | 227 | logger.info(`Server startup completed in ${startupDuration}ms (${checkpoints.length} checkpoints passed)`); 228 | 229 | } catch (error) { 230 | // Log startup error with checkpoint context (fire-and-forget, no await) 231 | const failedCheckpoint = findFailedCheckpoint(checkpoints); 232 | earlyLogger.logStartupError(failedCheckpoint, error); 233 | 234 | // In stdio mode, we cannot output to console at all 235 | if (mode !== 'stdio') { 236 | console.error('Failed to start MCP server:', error); 237 | logger.error('Failed to start MCP server', error); 238 | 239 | // Provide helpful error messages 240 | if (error instanceof Error && error.message.includes('nodes.db not found')) { 241 | console.error('\nTo fix this issue:'); 242 | console.error('1. cd to the n8n-mcp directory'); 243 | console.error('2. Run: npm run build'); 244 | console.error('3. Run: npm run rebuild'); 245 | } else if (error instanceof Error && error.message.includes('NODE_MODULE_VERSION')) { 246 | console.error('\nTo fix this Node.js version mismatch:'); 247 | console.error('1. cd to the n8n-mcp directory'); 248 | console.error('2. Run: npm rebuild better-sqlite3'); 249 | console.error('3. If that doesn\'t work, try: rm -rf node_modules && npm install'); 250 | } 251 | } 252 | 253 | process.exit(1); 254 | } 255 | } catch (outerError) { 256 | // Outer error catch for early initialization failures 257 | logger.error('Critical startup error:', outerError); 258 | process.exit(1); 259 | } 260 | } 261 | 262 | // Run if called directly 263 | if (require.main === module) { 264 | main().catch(console.error); 265 | } ``` -------------------------------------------------------------------------------- /docs/DOCKER_TROUBLESHOOTING.md: -------------------------------------------------------------------------------- ```markdown 1 | # Docker Troubleshooting Guide 2 | 3 | This guide helps resolve common issues when running n8n-mcp with Docker, especially when connecting to n8n instances. 4 | 5 | ## Table of Contents 6 | - [Common Issues](#common-issues) 7 | - [502 Bad Gateway Errors](#502-bad-gateway-errors) 8 | - [Custom Database Path Not Working](#custom-database-path-not-working-v27160) 9 | - [Container Name Conflicts](#container-name-conflicts) 10 | - [n8n API Connection Issues](#n8n-api-connection-issues) 11 | - [Docker Networking](#docker-networking) 12 | - [Quick Solutions](#quick-solutions) 13 | - [Debugging Steps](#debugging-steps) 14 | 15 | ## Common Issues 16 | 17 | ### Docker Configuration File Not Working (v2.8.2+) 18 | 19 | **Symptoms:** 20 | - Config file mounted but environment variables not set 21 | - Container starts but ignores configuration 22 | - Getting "permission denied" errors 23 | 24 | **Solutions:** 25 | 26 | 1. **Ensure file is mounted correctly:** 27 | ```bash 28 | # Correct - mount as read-only 29 | docker run -v $(pwd)/config.json:/app/config.json:ro ... 30 | 31 | # Check if file is accessible 32 | docker exec n8n-mcp cat /app/config.json 33 | ``` 34 | 35 | 2. **Verify JSON syntax:** 36 | ```bash 37 | # Validate JSON file 38 | cat config.json | jq . 39 | ``` 40 | 41 | 3. **Check Docker logs for parsing errors:** 42 | ```bash 43 | docker logs n8n-mcp | grep -i config 44 | ``` 45 | 46 | 4. **Common issues:** 47 | - Invalid JSON syntax (use a JSON validator) 48 | - File permissions (should be readable) 49 | - Wrong mount path (must be `/app/config.json`) 50 | - Dangerous variables blocked (PATH, LD_PRELOAD, etc.) 51 | 52 | ### Custom Database Path Not Working (v2.7.16+) 53 | 54 | **Symptoms:** 55 | - `NODE_DB_PATH` environment variable is set but ignored 56 | - Database always created at `/app/data/nodes.db` 57 | - Custom path setting has no effect 58 | 59 | **Root Cause:** Fixed in v2.7.16. Earlier versions had hardcoded paths in docker-entrypoint.sh. 60 | 61 | **Solutions:** 62 | 63 | 1. **Update to v2.7.16 or later:** 64 | ```bash 65 | docker pull ghcr.io/czlonkowski/n8n-mcp:latest 66 | ``` 67 | 68 | 2. **Ensure path ends with .db:** 69 | ```bash 70 | # Correct 71 | NODE_DB_PATH=/app/data/custom/my-nodes.db 72 | 73 | # Incorrect (will be rejected) 74 | NODE_DB_PATH=/app/data/custom/my-nodes 75 | ``` 76 | 77 | 3. **Use path within mounted volume for persistence:** 78 | ```yaml 79 | services: 80 | n8n-mcp: 81 | environment: 82 | NODE_DB_PATH: /app/data/custom/nodes.db 83 | volumes: 84 | - n8n-mcp-data:/app/data # Ensure parent directory is mounted 85 | ``` 86 | 87 | ### 502 Bad Gateway Errors 88 | 89 | **Symptoms:** 90 | - `n8n_health_check` returns 502 error 91 | - All n8n management API calls fail 92 | - n8n web UI is accessible but API is not 93 | 94 | **Root Cause:** Network connectivity issues between n8n-mcp container and n8n instance. 95 | 96 | **Solutions:** 97 | 98 | #### 1. When n8n runs in Docker on same machine 99 | 100 | Use Docker's special hostnames instead of `localhost`: 101 | 102 | ```json 103 | { 104 | "mcpServers": { 105 | "n8n-mcp": { 106 | "command": "docker", 107 | "args": [ 108 | "run", "-i", "--rm", 109 | "-e", "N8N_API_URL=http://host.docker.internal:5678", 110 | "-e", "N8N_API_KEY=your-api-key", 111 | "ghcr.io/czlonkowski/n8n-mcp:latest" 112 | ] 113 | } 114 | } 115 | } 116 | ``` 117 | 118 | **Alternative hostnames to try:** 119 | - `host.docker.internal` (Docker Desktop on macOS/Windows) 120 | - `172.17.0.1` (Default Docker bridge IP on Linux) 121 | - Your machine's actual IP address (e.g., `192.168.1.100`) 122 | 123 | #### 2. When both containers are in same Docker network 124 | 125 | ```bash 126 | # Create a shared network 127 | docker network create n8n-network 128 | 129 | # Run n8n in the network 130 | docker run -d --name n8n --network n8n-network -p 5678:5678 n8nio/n8n 131 | 132 | # Configure n8n-mcp to use container name 133 | ``` 134 | 135 | ```json 136 | { 137 | "N8N_API_URL": "http://n8n:5678" 138 | } 139 | ``` 140 | 141 | #### 3. For Docker Compose setups 142 | 143 | ```yaml 144 | # docker-compose.yml 145 | services: 146 | n8n: 147 | image: n8nio/n8n 148 | container_name: n8n 149 | networks: 150 | - n8n-net 151 | ports: 152 | - "5678:5678" 153 | 154 | n8n-mcp: 155 | image: ghcr.io/czlonkowski/n8n-mcp:latest 156 | environment: 157 | N8N_API_URL: http://n8n:5678 158 | N8N_API_KEY: ${N8N_API_KEY} 159 | networks: 160 | - n8n-net 161 | 162 | networks: 163 | n8n-net: 164 | driver: bridge 165 | ``` 166 | 167 | ### Container Cleanup Issues (Fixed in v2.7.20+) 168 | 169 | **Symptoms:** 170 | - Containers accumulate after Claude Desktop restarts 171 | - Containers show as "unhealthy" but don't clean up 172 | - `--rm` flag doesn't work as expected 173 | 174 | **Root Cause:** Fixed in v2.7.20 - containers weren't handling termination signals properly. 175 | 176 | **Solutions:** 177 | 178 | 1. **Update to v2.7.20+ and use --init flag (Recommended):** 179 | ```json 180 | { 181 | "command": "docker", 182 | "args": [ 183 | "run", "-i", "--rm", "--init", 184 | "ghcr.io/czlonkowski/n8n-mcp:latest" 185 | ] 186 | } 187 | ``` 188 | 189 | 2. **Manual cleanup of old containers:** 190 | ```bash 191 | # Remove all stopped n8n-mcp containers 192 | docker ps -a | grep n8n-mcp | grep Exited | awk '{print $1}' | xargs -r docker rm 193 | ``` 194 | 195 | 3. **For versions before 2.7.20:** 196 | - Manually clean up containers periodically 197 | - Consider using HTTP mode instead 198 | 199 | ### Webhooks to Local n8n Fail (v2.16.3+) 200 | 201 | **Symptoms:** 202 | - `n8n_trigger_webhook_workflow` fails with "SSRF protection" error 203 | - Error message: "SSRF protection: Localhost access is blocked" 204 | - Webhooks work from n8n UI but not from n8n-MCP 205 | 206 | **Root Cause:** Default strict SSRF protection blocks localhost access to prevent attacks. 207 | 208 | **Solution:** Use moderate security mode for local development 209 | 210 | ```bash 211 | # For Docker run 212 | docker run -d \ 213 | --name n8n-mcp \ 214 | -e MCP_MODE=http \ 215 | -e AUTH_TOKEN=your-token \ 216 | -e WEBHOOK_SECURITY_MODE=moderate \ 217 | -p 3000:3000 \ 218 | ghcr.io/czlonkowski/n8n-mcp:latest 219 | 220 | # For Docker Compose - add to environment: 221 | services: 222 | n8n-mcp: 223 | environment: 224 | WEBHOOK_SECURITY_MODE: moderate 225 | ``` 226 | 227 | **Security Modes Explained:** 228 | - `strict` (default): Blocks localhost + private IPs + cloud metadata (production) 229 | - `moderate`: Allows localhost, blocks private IPs + cloud metadata (local development) 230 | - `permissive`: Allows localhost + private IPs, blocks cloud metadata (testing only) 231 | 232 | **Important:** Always use `strict` mode in production. Cloud metadata is blocked in all modes. 233 | 234 | ### n8n API Connection Issues 235 | 236 | **Symptoms:** 237 | - API calls fail but n8n web UI works 238 | - Authentication errors 239 | - API endpoints return 404 240 | 241 | **Solutions:** 242 | 243 | 1. **Verify n8n API is enabled:** 244 | - Check n8n settings → REST API is enabled 245 | - Ensure API key is valid and not expired 246 | 247 | 2. **Test API directly:** 248 | ```bash 249 | # From host machine 250 | curl -H "X-N8N-API-KEY: your-key" http://localhost:5678/api/v1/workflows 251 | 252 | # From inside Docker container 253 | docker run --rm curlimages/curl \ 254 | -H "X-N8N-API-KEY: your-key" \ 255 | http://host.docker.internal:5678/api/v1/workflows 256 | ``` 257 | 258 | 3. **Check n8n environment variables:** 259 | ```yaml 260 | environment: 261 | - N8N_BASIC_AUTH_ACTIVE=true 262 | - N8N_BASIC_AUTH_USER=user 263 | - N8N_BASIC_AUTH_PASSWORD=password 264 | ``` 265 | 266 | ## Docker Networking 267 | 268 | ### Understanding Docker Network Modes 269 | 270 | | Scenario | Use This URL | Why | 271 | |----------|--------------|-----| 272 | | n8n on host, n8n-mcp in Docker | `http://host.docker.internal:5678` | Docker can't reach host's localhost | 273 | | Both in same Docker network | `http://container-name:5678` | Direct container-to-container | 274 | | n8n behind reverse proxy | `http://your-domain.com` | Use public URL | 275 | | Local development | `http://YOUR_LOCAL_IP:5678` | Use machine's IP address | 276 | 277 | ### Finding Your Configuration 278 | 279 | ```bash 280 | # Check if n8n is running in Docker 281 | docker ps | grep n8n 282 | 283 | # Find Docker network 284 | docker network ls 285 | 286 | # Get container details 287 | docker inspect n8n | grep NetworkMode 288 | 289 | # Find your local IP 290 | # macOS/Linux 291 | ifconfig | grep "inet " | grep -v 127.0.0.1 292 | 293 | # Windows 294 | ipconfig | findstr IPv4 295 | ``` 296 | 297 | ## Quick Solutions 298 | 299 | ### Solution 1: Use Host Network (Linux only) 300 | ```json 301 | { 302 | "command": "docker", 303 | "args": [ 304 | "run", "-i", "--rm", 305 | "--network", "host", 306 | "-e", "N8N_API_URL=http://localhost:5678", 307 | "ghcr.io/czlonkowski/n8n-mcp:latest" 308 | ] 309 | } 310 | ``` 311 | 312 | ### Solution 2: Use Your Machine's IP 313 | ```json 314 | { 315 | "N8N_API_URL": "http://192.168.1.100:5678" // Replace with your IP 316 | } 317 | ``` 318 | 319 | ### Solution 3: HTTP Mode Deployment 320 | Deploy n8n-mcp as HTTP server to avoid stdio/Docker issues: 321 | 322 | ```bash 323 | # Start HTTP server 324 | docker run -d \ 325 | -p 3000:3000 \ 326 | -e MCP_MODE=http \ 327 | -e AUTH_TOKEN=your-token \ 328 | -e N8N_API_URL=http://host.docker.internal:5678 \ 329 | -e N8N_API_KEY=your-n8n-key \ 330 | ghcr.io/czlonkowski/n8n-mcp:latest 331 | 332 | # Configure Claude with mcp-remote 333 | ``` 334 | 335 | ## Debugging Steps 336 | 337 | ### 1. Enable Debug Logging 338 | ```json 339 | { 340 | "env": { 341 | "LOG_LEVEL": "debug", 342 | "DEBUG_MCP": "true" 343 | } 344 | } 345 | ``` 346 | 347 | ### 2. Test Connectivity 348 | ```bash 349 | # Test from n8n-mcp container 350 | docker run --rm ghcr.io/czlonkowski/n8n-mcp:latest \ 351 | sh -c "apk add curl && curl -v http://host.docker.internal:5678/api/v1/workflows" 352 | ``` 353 | 354 | ### 3. Check Docker Logs 355 | ```bash 356 | # View n8n-mcp logs 357 | docker logs $(docker ps -q -f ancestor=ghcr.io/czlonkowski/n8n-mcp:latest) 358 | 359 | # View n8n logs 360 | docker logs n8n 361 | ``` 362 | 363 | ### 4. Validate Environment 364 | ```bash 365 | # Check what n8n-mcp sees 366 | docker run --rm ghcr.io/czlonkowski/n8n-mcp:latest \ 367 | sh -c "env | grep N8N" 368 | ``` 369 | 370 | ### 5. Network Diagnostics 371 | ```bash 372 | # Check Docker networks 373 | docker network inspect bridge 374 | 375 | # Test DNS resolution 376 | docker run --rm busybox nslookup host.docker.internal 377 | ``` 378 | 379 | ## Platform-Specific Notes 380 | 381 | ### Docker Desktop (macOS/Windows) 382 | - `host.docker.internal` works out of the box 383 | - Ensure Docker Desktop is running 384 | - Check Docker Desktop settings → Resources → Network 385 | 386 | ### Linux 387 | - `host.docker.internal` requires Docker 20.10+ 388 | - Alternative: Use `--add-host=host.docker.internal:host-gateway` 389 | - Or use the Docker bridge IP: `172.17.0.1` 390 | 391 | ### Windows with WSL2 392 | - Use `host.docker.internal` or WSL2 IP 393 | - Check firewall rules for port 5678 394 | - Ensure n8n binds to `0.0.0.0` not `127.0.0.1` 395 | 396 | ## Still Having Issues? 397 | 398 | 1. **Check n8n logs** for API-related errors 399 | 2. **Verify firewall/security** isn't blocking connections 400 | 3. **Try simpler setup** - Run n8n-mcp on host instead of Docker 401 | 4. **Report issue** with debug logs at [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues) 402 | 403 | ## Useful Commands 404 | 405 | ```bash 406 | # Remove all n8n-mcp containers 407 | docker rm -f $(docker ps -aq -f ancestor=ghcr.io/czlonkowski/n8n-mcp:latest) 408 | 409 | # Test n8n API with curl 410 | curl -H "X-N8N-API-KEY: your-key" http://localhost:5678/api/v1/workflows 411 | 412 | # Run interactive debug session 413 | docker run -it --rm \ 414 | -e LOG_LEVEL=debug \ 415 | -e N8N_API_URL=http://host.docker.internal:5678 \ 416 | -e N8N_API_KEY=your-key \ 417 | ghcr.io/czlonkowski/n8n-mcp:latest \ 418 | sh 419 | 420 | # Check container networking 421 | docker run --rm alpine ping -c 4 host.docker.internal 422 | ``` ``` -------------------------------------------------------------------------------- /tests/fixtures/factories/parser-node.factory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Factory } from 'fishery'; 2 | import { faker } from '@faker-js/faker'; 3 | 4 | // Declarative node definition 5 | export interface DeclarativeNodeDefinition { 6 | name: string; 7 | displayName: string; 8 | description: string; 9 | version?: number | number[]; 10 | group?: string[]; 11 | categories?: string[]; 12 | routing: { 13 | request?: { 14 | resource?: { 15 | options: Array<{ name: string; value: string }>; 16 | }; 17 | operation?: { 18 | options: Record<string, Array<{ name: string; value: string; action?: string }>>; 19 | }; 20 | }; 21 | }; 22 | properties?: any[]; 23 | credentials?: any[]; 24 | usableAsTool?: boolean; 25 | webhooks?: any[]; 26 | polling?: boolean; 27 | } 28 | 29 | // Programmatic node definition 30 | export interface ProgrammaticNodeDefinition { 31 | name: string; 32 | displayName: string; 33 | description: string; 34 | version?: number | number[]; 35 | group?: string[]; 36 | categories?: string[]; 37 | properties: any[]; 38 | credentials?: any[]; 39 | usableAsTool?: boolean; 40 | webhooks?: any[]; 41 | polling?: boolean; 42 | trigger?: boolean; 43 | eventTrigger?: boolean; 44 | } 45 | 46 | // Versioned node class structure 47 | export interface VersionedNodeClass { 48 | baseDescription?: { 49 | name: string; 50 | displayName: string; 51 | description: string; 52 | defaultVersion: number; 53 | }; 54 | nodeVersions?: Record<number, { description: any }>; 55 | } 56 | 57 | // Property definition 58 | export interface PropertyDefinition { 59 | displayName: string; 60 | name: string; 61 | type: string; 62 | default?: any; 63 | description?: string; 64 | options?: Array<{ name: string; value: string; description?: string; action?: string; displayName?: string }> | any[]; 65 | required?: boolean; 66 | displayOptions?: { 67 | show?: Record<string, any[]>; 68 | hide?: Record<string, any[]>; 69 | }; 70 | typeOptions?: any; 71 | noDataExpression?: boolean; 72 | } 73 | 74 | // Base property factory 75 | export const propertyFactory = Factory.define<PropertyDefinition>(() => ({ 76 | displayName: faker.helpers.arrayElement(['Resource', 'Operation', 'Field', 'Option']), 77 | name: faker.helpers.slugify(faker.word.noun()).toLowerCase(), 78 | type: faker.helpers.arrayElement(['string', 'number', 'boolean', 'options', 'json', 'collection']), 79 | default: '', 80 | description: faker.lorem.sentence(), 81 | required: faker.datatype.boolean(), 82 | noDataExpression: faker.datatype.boolean() 83 | })); 84 | 85 | // String property factory 86 | export const stringPropertyFactory = propertyFactory.params({ 87 | type: 'string', 88 | default: faker.lorem.word() 89 | }); 90 | 91 | // Number property factory 92 | export const numberPropertyFactory = propertyFactory.params({ 93 | type: 'number', 94 | default: faker.number.int({ min: 0, max: 100 }) 95 | }); 96 | 97 | // Boolean property factory 98 | export const booleanPropertyFactory = propertyFactory.params({ 99 | type: 'boolean', 100 | default: faker.datatype.boolean() 101 | }); 102 | 103 | // Options property factory 104 | export const optionsPropertyFactory = propertyFactory.params({ 105 | type: 'options', 106 | options: [ 107 | { name: 'Option A', value: 'a', description: 'First option' }, 108 | { name: 'Option B', value: 'b', description: 'Second option' }, 109 | { name: 'Option C', value: 'c', description: 'Third option' } 110 | ], 111 | default: 'a' 112 | }); 113 | 114 | // Resource property for programmatic nodes 115 | export const resourcePropertyFactory = optionsPropertyFactory.params({ 116 | displayName: 'Resource', 117 | name: 'resource', 118 | options: [ 119 | { name: 'User', value: 'user' }, 120 | { name: 'Post', value: 'post' }, 121 | { name: 'Comment', value: 'comment' } 122 | ] 123 | }); 124 | 125 | // Operation property for programmatic nodes 126 | export const operationPropertyFactory = optionsPropertyFactory.params({ 127 | displayName: 'Operation', 128 | name: 'operation', 129 | displayOptions: { 130 | show: { 131 | resource: ['user'] 132 | } 133 | }, 134 | options: [ 135 | { name: 'Create', value: 'create', action: 'Create a user' } as any, 136 | { name: 'Get', value: 'get', action: 'Get a user' } as any, 137 | { name: 'Update', value: 'update', action: 'Update a user' } as any, 138 | { name: 'Delete', value: 'delete', action: 'Delete a user' } as any 139 | ] 140 | }); 141 | 142 | // Collection property factory 143 | export const collectionPropertyFactory = propertyFactory.params({ 144 | type: 'collection', 145 | default: {}, 146 | options: [ 147 | stringPropertyFactory.build({ name: 'field1', displayName: 'Field 1' }) as any, 148 | numberPropertyFactory.build({ name: 'field2', displayName: 'Field 2' }) as any 149 | ] 150 | }); 151 | 152 | // Declarative node factory 153 | export const declarativeNodeFactory = Factory.define<DeclarativeNodeDefinition>(() => ({ 154 | name: faker.helpers.slugify(faker.company.name()).toLowerCase(), 155 | displayName: faker.company.name(), 156 | description: faker.lorem.sentence(), 157 | version: faker.number.int({ min: 1, max: 3 }), 158 | group: [faker.helpers.arrayElement(['transform', 'output'])], 159 | routing: { 160 | request: { 161 | resource: { 162 | options: [ 163 | { name: 'User', value: 'user' }, 164 | { name: 'Post', value: 'post' } 165 | ] 166 | }, 167 | operation: { 168 | options: { 169 | user: [ 170 | { name: 'Create', value: 'create', action: 'Create a user' }, 171 | { name: 'Get', value: 'get', action: 'Get a user' } 172 | ], 173 | post: [ 174 | { name: 'Create', value: 'create', action: 'Create a post' }, 175 | { name: 'List', value: 'list', action: 'List posts' } 176 | ] 177 | } 178 | } 179 | } 180 | }, 181 | properties: [ 182 | stringPropertyFactory.build({ name: 'apiKey', displayName: 'API Key' }) 183 | ], 184 | credentials: [ 185 | { name: 'apiCredentials', required: true } 186 | ] 187 | })); 188 | 189 | // Programmatic node factory 190 | export const programmaticNodeFactory = Factory.define<ProgrammaticNodeDefinition>(() => ({ 191 | name: faker.helpers.slugify(faker.company.name()).toLowerCase(), 192 | displayName: faker.company.name(), 193 | description: faker.lorem.sentence(), 194 | version: faker.number.int({ min: 1, max: 3 }), 195 | group: [faker.helpers.arrayElement(['transform', 'output'])], 196 | properties: [ 197 | resourcePropertyFactory.build(), 198 | operationPropertyFactory.build(), 199 | stringPropertyFactory.build({ 200 | name: 'field', 201 | displayName: 'Field', 202 | displayOptions: { 203 | show: { 204 | resource: ['user'], 205 | operation: ['create', 'update'] 206 | } 207 | } 208 | }) 209 | ], 210 | credentials: [] 211 | })); 212 | 213 | // Trigger node factory 214 | export const triggerNodeFactory = programmaticNodeFactory.params({ 215 | group: ['trigger'], 216 | trigger: true, 217 | properties: [ 218 | { 219 | displayName: 'Event', 220 | name: 'event', 221 | type: 'options', 222 | default: 'created', 223 | options: [ 224 | { name: 'Created', value: 'created' }, 225 | { name: 'Updated', value: 'updated' }, 226 | { name: 'Deleted', value: 'deleted' } 227 | ] 228 | } 229 | ] 230 | }); 231 | 232 | // Webhook node factory 233 | export const webhookNodeFactory = programmaticNodeFactory.params({ 234 | group: ['trigger'], 235 | webhooks: [ 236 | { 237 | name: 'default', 238 | httpMethod: 'POST', 239 | responseMode: 'onReceived', 240 | path: 'webhook' 241 | } 242 | ], 243 | properties: [ 244 | { 245 | displayName: 'Path', 246 | name: 'path', 247 | type: 'string', 248 | default: 'webhook', 249 | required: true 250 | } 251 | ] 252 | }); 253 | 254 | // AI tool node factory 255 | export const aiToolNodeFactory = declarativeNodeFactory.params({ 256 | usableAsTool: true, 257 | name: 'openai', 258 | displayName: 'OpenAI', 259 | description: 'Use OpenAI models' 260 | }); 261 | 262 | // Versioned node class factory 263 | export const versionedNodeClassFactory = Factory.define<VersionedNodeClass>(() => ({ 264 | baseDescription: { 265 | name: faker.helpers.slugify(faker.company.name()).toLowerCase(), 266 | displayName: faker.company.name(), 267 | description: faker.lorem.sentence(), 268 | defaultVersion: 2 269 | }, 270 | nodeVersions: { 271 | 1: { 272 | description: { 273 | properties: [ 274 | stringPropertyFactory.build({ name: 'oldField', displayName: 'Old Field' }) 275 | ] 276 | } 277 | }, 278 | 2: { 279 | description: { 280 | properties: [ 281 | stringPropertyFactory.build({ name: 'newField', displayName: 'New Field' }), 282 | numberPropertyFactory.build({ name: 'version', displayName: 'Version' }) 283 | ] 284 | } 285 | } 286 | } 287 | })); 288 | 289 | // Malformed node factory (for error testing) 290 | export const malformedNodeFactory = Factory.define<any>(() => ({ 291 | // Missing required 'name' property 292 | displayName: faker.company.name(), 293 | description: faker.lorem.sentence() 294 | })); 295 | 296 | // Complex nested property factory 297 | export const nestedPropertyFactory = Factory.define<PropertyDefinition>(() => ({ 298 | displayName: 'Advanced Options', 299 | name: 'advancedOptions', 300 | type: 'collection', 301 | default: {}, 302 | options: [ 303 | { 304 | displayName: 'Headers', 305 | name: 'headers', 306 | type: 'fixedCollection', 307 | typeOptions: { 308 | multipleValues: true 309 | }, 310 | options: [ 311 | { 312 | name: 'header', 313 | displayName: 'Header', 314 | values: [ 315 | stringPropertyFactory.build({ name: 'name', displayName: 'Name' }), 316 | stringPropertyFactory.build({ name: 'value', displayName: 'Value' }) 317 | ] 318 | } 319 | ] 320 | } as any, 321 | { 322 | displayName: 'Query Parameters', 323 | name: 'queryParams', 324 | type: 'collection', 325 | options: [ 326 | stringPropertyFactory.build({ name: 'key', displayName: 'Key' }), 327 | stringPropertyFactory.build({ name: 'value', displayName: 'Value' }) 328 | ] as any[] 329 | } as any 330 | ] 331 | })); 332 | 333 | // Node class mock factory 334 | export const nodeClassFactory = Factory.define<any>(({ params }) => { 335 | const description = params.description || programmaticNodeFactory.build(); 336 | 337 | return class MockNode { 338 | description = description; 339 | 340 | constructor() { 341 | // Constructor logic if needed 342 | } 343 | }; 344 | }); 345 | 346 | // Versioned node type class mock 347 | export const versionedNodeTypeClassFactory = Factory.define<any>(({ params }) => { 348 | const baseDescription = params.baseDescription || { 349 | name: 'versionedNode', 350 | displayName: 'Versioned Node', 351 | description: 'A versioned node', 352 | defaultVersion: 2 353 | }; 354 | 355 | const nodeVersions = params.nodeVersions || { 356 | 1: { 357 | description: { 358 | properties: [propertyFactory.build()] 359 | } 360 | }, 361 | 2: { 362 | description: { 363 | properties: [propertyFactory.build(), propertyFactory.build()] 364 | } 365 | } 366 | }; 367 | 368 | return class VersionedNodeType { 369 | baseDescription = baseDescription; 370 | nodeVersions = nodeVersions; 371 | currentVersion = baseDescription.defaultVersion; 372 | 373 | constructor() { 374 | Object.defineProperty(this.constructor, 'name', { 375 | value: 'VersionedNodeType', 376 | writable: false, 377 | configurable: true 378 | }); 379 | } 380 | }; 381 | }); ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/protocol-compliance.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; 3 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 4 | import { TestableN8NMCPServer } from './test-helpers'; 5 | 6 | describe('MCP Protocol Compliance', () => { 7 | let mcpServer: TestableN8NMCPServer; 8 | let transport: InMemoryTransport; 9 | let client: Client; 10 | 11 | beforeEach(async () => { 12 | mcpServer = new TestableN8NMCPServer(); 13 | await mcpServer.initialize(); 14 | 15 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 16 | transport = serverTransport; 17 | 18 | // Connect MCP server to transport 19 | await mcpServer.connectToTransport(transport); 20 | 21 | // Create client 22 | client = new Client({ 23 | name: 'test-client', 24 | version: '1.0.0' 25 | }, { 26 | capabilities: {} 27 | }); 28 | 29 | await client.connect(clientTransport); 30 | }); 31 | 32 | afterEach(async () => { 33 | await client.close(); 34 | await mcpServer.close(); 35 | }); 36 | 37 | describe('JSON-RPC 2.0 Compliance', () => { 38 | it('should return proper JSON-RPC 2.0 response format', async () => { 39 | const response = await client.listTools(); 40 | 41 | // Response should have tools array 42 | expect(response).toHaveProperty('tools'); 43 | expect(Array.isArray((response as any).tools)).toBe(true); 44 | }); 45 | 46 | it('should handle request with id correctly', async () => { 47 | const response = await client.listTools(); 48 | 49 | expect(response).toBeDefined(); 50 | expect(typeof response).toBe('object'); 51 | }); 52 | 53 | it('should handle batch requests', async () => { 54 | // Send multiple requests concurrently 55 | const promises = [ 56 | client.listTools(), 57 | client.listTools(), 58 | client.listTools() 59 | ]; 60 | 61 | const responses = await Promise.all(promises); 62 | 63 | expect(responses).toHaveLength(3); 64 | responses.forEach(response => { 65 | expect(response).toHaveProperty('tools'); 66 | }); 67 | }); 68 | 69 | it('should preserve request order in responses', async () => { 70 | const requests = []; 71 | const expectedOrder = []; 72 | 73 | // Create requests with different tools to track order 74 | for (let i = 0; i < 5; i++) { 75 | expectedOrder.push(i); 76 | requests.push( 77 | client.callTool({ name: 'get_database_statistics', arguments: {} }) 78 | .then(() => i) 79 | ); 80 | } 81 | 82 | const results = await Promise.all(requests); 83 | expect(results).toEqual(expectedOrder); 84 | }); 85 | }); 86 | 87 | describe('Protocol Version Negotiation', () => { 88 | it('should negotiate protocol capabilities', async () => { 89 | const serverInfo = await client.getServerVersion(); 90 | 91 | expect(serverInfo).toHaveProperty('name'); 92 | expect(serverInfo).toHaveProperty('version'); 93 | expect(serverInfo!.name).toBe('n8n-documentation-mcp'); 94 | }); 95 | 96 | it('should expose supported capabilities', async () => { 97 | const serverCapabilities = client.getServerCapabilities(); 98 | 99 | expect(serverCapabilities).toBeDefined(); 100 | 101 | // Should support tools 102 | expect(serverCapabilities).toHaveProperty('tools'); 103 | }); 104 | }); 105 | 106 | describe('Message Format Validation', () => { 107 | it('should reject messages without method', async () => { 108 | // Test by sending raw message through transport 109 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 110 | const testClient = new Client({ name: 'test', version: '1.0.0' }, {}); 111 | 112 | await mcpServer.connectToTransport(serverTransport); 113 | await testClient.connect(clientTransport); 114 | 115 | try { 116 | // This should fail as MCP SDK validates method 117 | await (testClient as any).request({ method: '', params: {} }); 118 | expect.fail('Should have thrown an error'); 119 | } catch (error) { 120 | expect(error).toBeDefined(); 121 | } finally { 122 | await testClient.close(); 123 | } 124 | }); 125 | 126 | it('should handle missing params gracefully', async () => { 127 | // Most tools should work without params 128 | const response = await client.callTool({ name: 'list_nodes', arguments: {} }); 129 | expect(response).toBeDefined(); 130 | }); 131 | 132 | it('should validate params schema', async () => { 133 | try { 134 | // Invalid nodeType format (missing prefix) 135 | const response = await client.callTool({ name: 'get_node_info', arguments: { 136 | nodeType: 'httpRequest' // Should be 'nodes-base.httpRequest' 137 | } }); 138 | // Check if the response indicates an error 139 | const text = (response as any).content[0].text; 140 | expect(text).toContain('not found'); 141 | } catch (error: any) { 142 | // If it throws, that's also acceptable 143 | expect(error.message).toContain('not found'); 144 | } 145 | }); 146 | }); 147 | 148 | describe('Content Types', () => { 149 | it('should handle text content in tool responses', async () => { 150 | const response = await client.callTool({ name: 'get_database_statistics', arguments: {} }); 151 | 152 | expect((response as any).content).toHaveLength(1); 153 | expect((response as any).content[0]).toHaveProperty('type', 'text'); 154 | expect((response as any).content[0]).toHaveProperty('text'); 155 | expect(typeof (response as any).content[0].text).toBe('string'); 156 | }); 157 | 158 | it('should handle large text responses', async () => { 159 | // Get a large node info response 160 | const response = await client.callTool({ name: 'get_node_info', arguments: { 161 | nodeType: 'nodes-base.httpRequest' 162 | } }); 163 | 164 | expect((response as any).content).toHaveLength(1); 165 | expect((response as any).content[0].type).toBe('text'); 166 | expect((response as any).content[0].text.length).toBeGreaterThan(1000); 167 | }); 168 | 169 | it('should handle JSON content properly', async () => { 170 | const response = await client.callTool({ name: 'list_nodes', arguments: { 171 | limit: 5 172 | } }); 173 | 174 | expect((response as any).content).toHaveLength(1); 175 | const content = JSON.parse((response as any).content[0].text); 176 | expect(content).toHaveProperty('nodes'); 177 | expect(Array.isArray(content.nodes)).toBe(true); 178 | }); 179 | }); 180 | 181 | describe('Request/Response Correlation', () => { 182 | it('should correlate concurrent requests correctly', async () => { 183 | const requests = [ 184 | client.callTool({ name: 'get_node_essentials', arguments: { nodeType: 'nodes-base.httpRequest' } }), 185 | client.callTool({ name: 'get_node_essentials', arguments: { nodeType: 'nodes-base.webhook' } }), 186 | client.callTool({ name: 'get_node_essentials', arguments: { nodeType: 'nodes-base.slack' } }) 187 | ]; 188 | 189 | const responses = await Promise.all(requests); 190 | 191 | expect((responses[0] as any).content[0].text).toContain('httpRequest'); 192 | expect((responses[1] as any).content[0].text).toContain('webhook'); 193 | expect((responses[2] as any).content[0].text).toContain('slack'); 194 | }); 195 | 196 | it('should handle interleaved requests', async () => { 197 | const results: string[] = []; 198 | 199 | // Start multiple requests with different delays 200 | const p1 = client.callTool({ name: 'get_database_statistics', arguments: {} }) 201 | .then(() => { results.push('stats'); return 'stats'; }); 202 | 203 | const p2 = client.callTool({ name: 'list_nodes', arguments: { limit: 1 } }) 204 | .then(() => { results.push('nodes'); return 'nodes'; }); 205 | 206 | const p3 = client.callTool({ name: 'search_nodes', arguments: { query: 'http' } }) 207 | .then(() => { results.push('search'); return 'search'; }); 208 | 209 | const resolved = await Promise.all([p1, p2, p3]); 210 | 211 | // All should complete 212 | expect(resolved).toHaveLength(3); 213 | expect(results).toHaveLength(3); 214 | }); 215 | }); 216 | 217 | describe('Protocol Extensions', () => { 218 | it('should handle tool-specific extensions', async () => { 219 | // Test tool with complex params 220 | const response = await client.callTool({ name: 'validate_node_operation', arguments: { 221 | nodeType: 'nodes-base.httpRequest', 222 | config: { 223 | method: 'GET', 224 | url: 'https://api.example.com' 225 | }, 226 | profile: 'runtime' 227 | } }); 228 | 229 | expect((response as any).content).toHaveLength(1); 230 | expect((response as any).content[0].type).toBe('text'); 231 | }); 232 | 233 | it('should support optional parameters', async () => { 234 | // Call with minimal params 235 | const response1 = await client.callTool({ name: 'list_nodes', arguments: {} }); 236 | 237 | // Call with all params 238 | const response2 = await client.callTool({ name: 'list_nodes', arguments: { 239 | limit: 10, 240 | category: 'trigger', 241 | package: 'n8n-nodes-base' 242 | } }); 243 | 244 | expect(response1).toBeDefined(); 245 | expect(response2).toBeDefined(); 246 | }); 247 | }); 248 | 249 | describe('Transport Layer', () => { 250 | it('should handle transport disconnection gracefully', async () => { 251 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 252 | const testClient = new Client({ name: 'test', version: '1.0.0' }, {}); 253 | 254 | await mcpServer.connectToTransport(serverTransport); 255 | await testClient.connect(clientTransport); 256 | 257 | // Make a request 258 | const response = await testClient.callTool({ name: 'get_database_statistics', arguments: {} }); 259 | expect(response).toBeDefined(); 260 | 261 | // Close client 262 | await testClient.close(); 263 | 264 | // Further requests should fail 265 | try { 266 | await testClient.callTool({ name: 'get_database_statistics', arguments: {} }); 267 | expect.fail('Should have thrown an error'); 268 | } catch (error) { 269 | expect(error).toBeDefined(); 270 | } 271 | }); 272 | 273 | it('should handle multiple sequential connections', async () => { 274 | // Close existing connection 275 | await client.close(); 276 | await mcpServer.close(); 277 | 278 | // Create new connections 279 | for (let i = 0; i < 3; i++) { 280 | const engine = new TestableN8NMCPServer(); 281 | await engine.initialize(); 282 | 283 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 284 | await engine.connectToTransport(serverTransport); 285 | 286 | const testClient = new Client({ name: 'test', version: '1.0.0' }, {}); 287 | await testClient.connect(clientTransport); 288 | 289 | const response = await testClient.callTool({ name: 'get_database_statistics', arguments: {} }); 290 | expect(response).toBeDefined(); 291 | 292 | await testClient.close(); 293 | await engine.close(); 294 | } 295 | }); 296 | }); 297 | }); ``` -------------------------------------------------------------------------------- /tests/integration/ai-validation/llm-chain-validation.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration Tests: Basic LLM Chain Validation 3 | * 4 | * Tests Basic LLM Chain validation against real n8n instance. 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; 8 | import { createTestContext, TestContext, createTestWorkflowName } from '../n8n-api/utils/test-context'; 9 | import { getTestN8nClient } from '../n8n-api/utils/n8n-client'; 10 | import { N8nApiClient } from '../../../src/services/n8n-api-client'; 11 | import { cleanupOrphanedWorkflows } from '../n8n-api/utils/cleanup-helpers'; 12 | import { createMcpContext } from '../n8n-api/utils/mcp-context'; 13 | import { InstanceContext } from '../../../src/types/instance-context'; 14 | import { handleValidateWorkflow } from '../../../src/mcp/handlers-n8n-manager'; 15 | import { getNodeRepository, closeNodeRepository } from '../n8n-api/utils/node-repository'; 16 | import { NodeRepository } from '../../../src/database/node-repository'; 17 | import { ValidationResponse } from '../n8n-api/types/mcp-responses'; 18 | import { 19 | createBasicLLMChainNode, 20 | createLanguageModelNode, 21 | createMemoryNode, 22 | createAIConnection, 23 | mergeConnections, 24 | createAIWorkflow 25 | } from './helpers'; 26 | import { WorkflowNode } from '../../../src/types/n8n-api'; 27 | 28 | describe('Integration: Basic LLM Chain Validation', () => { 29 | let context: TestContext; 30 | let client: N8nApiClient; 31 | let mcpContext: InstanceContext; 32 | let repository: NodeRepository; 33 | 34 | beforeEach(async () => { 35 | context = createTestContext(); 36 | client = getTestN8nClient(); 37 | mcpContext = createMcpContext(); 38 | repository = await getNodeRepository(); 39 | }); 40 | 41 | afterEach(async () => { 42 | await context.cleanup(); 43 | }); 44 | 45 | afterAll(async () => { 46 | await closeNodeRepository(); 47 | if (!process.env.CI) { 48 | await cleanupOrphanedWorkflows(); 49 | } 50 | }); 51 | 52 | // ====================================================================== 53 | // TEST 1: Missing Language Model 54 | // ====================================================================== 55 | 56 | it('should detect missing language model', async () => { 57 | const llmChain = createBasicLLMChainNode({ 58 | name: 'Basic LLM Chain', 59 | promptType: 'define', 60 | text: 'Test prompt' 61 | }); 62 | 63 | const workflow = createAIWorkflow( 64 | [llmChain], 65 | {}, // No connections 66 | { 67 | name: createTestWorkflowName('LLM Chain - Missing Model'), 68 | tags: ['mcp-integration-test', 'ai-validation'] 69 | } 70 | ); 71 | 72 | const created = await client.createWorkflow(workflow); 73 | context.trackWorkflow(created.id!); 74 | 75 | const response = await handleValidateWorkflow( 76 | { id: created.id }, 77 | repository, 78 | mcpContext 79 | ); 80 | 81 | expect(response.success).toBe(true); 82 | const data = response.data as ValidationResponse; 83 | 84 | expect(data.valid).toBe(false); 85 | expect(data.errors).toBeDefined(); 86 | 87 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 88 | expect(errorCodes).toContain('MISSING_LANGUAGE_MODEL'); 89 | }); 90 | 91 | // ====================================================================== 92 | // TEST 2: Missing Prompt Text (promptType=define) 93 | // ====================================================================== 94 | 95 | it('should detect missing prompt text', async () => { 96 | const languageModel = createLanguageModelNode('openai', { 97 | name: 'OpenAI Chat Model' 98 | }); 99 | 100 | const llmChain = createBasicLLMChainNode({ 101 | name: 'Basic LLM Chain', 102 | promptType: 'define', 103 | text: '' // Empty prompt text 104 | }); 105 | 106 | const workflow = createAIWorkflow( 107 | [languageModel, llmChain], 108 | createAIConnection('OpenAI Chat Model', 'Basic LLM Chain', 'ai_languageModel'), 109 | { 110 | name: createTestWorkflowName('LLM Chain - Missing Prompt'), 111 | tags: ['mcp-integration-test', 'ai-validation'] 112 | } 113 | ); 114 | 115 | const created = await client.createWorkflow(workflow); 116 | context.trackWorkflow(created.id!); 117 | 118 | const response = await handleValidateWorkflow( 119 | { id: created.id }, 120 | repository, 121 | mcpContext 122 | ); 123 | 124 | expect(response.success).toBe(true); 125 | const data = response.data as ValidationResponse; 126 | 127 | expect(data.valid).toBe(false); 128 | expect(data.errors).toBeDefined(); 129 | 130 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 131 | expect(errorCodes).toContain('MISSING_PROMPT_TEXT'); 132 | }); 133 | 134 | // ====================================================================== 135 | // TEST 3: Valid Complete LLM Chain 136 | // ====================================================================== 137 | 138 | it('should validate complete LLM Chain', async () => { 139 | const languageModel = createLanguageModelNode('openai', { 140 | name: 'OpenAI Chat Model' 141 | }); 142 | 143 | const llmChain = createBasicLLMChainNode({ 144 | name: 'Basic LLM Chain', 145 | promptType: 'define', 146 | text: 'You are a helpful assistant. Answer the following: {{ $json.question }}' 147 | }); 148 | 149 | const workflow = createAIWorkflow( 150 | [languageModel, llmChain], 151 | createAIConnection('OpenAI Chat Model', 'Basic LLM Chain', 'ai_languageModel'), 152 | { 153 | name: createTestWorkflowName('LLM Chain - Valid'), 154 | tags: ['mcp-integration-test', 'ai-validation'] 155 | } 156 | ); 157 | 158 | const created = await client.createWorkflow(workflow); 159 | context.trackWorkflow(created.id!); 160 | 161 | const response = await handleValidateWorkflow( 162 | { id: created.id }, 163 | repository, 164 | mcpContext 165 | ); 166 | 167 | expect(response.success).toBe(true); 168 | const data = response.data as ValidationResponse; 169 | 170 | expect(data.valid).toBe(true); 171 | expect(data.errors).toBeUndefined(); 172 | expect(data.summary.errorCount).toBe(0); 173 | }); 174 | 175 | // ====================================================================== 176 | // TEST 4: LLM Chain with Memory 177 | // ====================================================================== 178 | 179 | it('should validate LLM Chain with memory', async () => { 180 | const languageModel = createLanguageModelNode('anthropic', { 181 | name: 'Anthropic Chat Model' 182 | }); 183 | 184 | const memory = createMemoryNode({ 185 | name: 'Window Buffer Memory', 186 | contextWindowLength: 10 187 | }); 188 | 189 | const llmChain = createBasicLLMChainNode({ 190 | name: 'Basic LLM Chain', 191 | promptType: 'auto' 192 | }); 193 | 194 | const workflow = createAIWorkflow( 195 | [languageModel, memory, llmChain], 196 | mergeConnections( 197 | createAIConnection('Anthropic Chat Model', 'Basic LLM Chain', 'ai_languageModel'), 198 | createAIConnection('Window Buffer Memory', 'Basic LLM Chain', 'ai_memory') 199 | ), 200 | { 201 | name: createTestWorkflowName('LLM Chain - With Memory'), 202 | tags: ['mcp-integration-test', 'ai-validation'] 203 | } 204 | ); 205 | 206 | const created = await client.createWorkflow(workflow); 207 | context.trackWorkflow(created.id!); 208 | 209 | const response = await handleValidateWorkflow( 210 | { id: created.id }, 211 | repository, 212 | mcpContext 213 | ); 214 | 215 | expect(response.success).toBe(true); 216 | const data = response.data as ValidationResponse; 217 | 218 | expect(data.valid).toBe(true); 219 | expect(data.errors).toBeUndefined(); 220 | }); 221 | 222 | // ====================================================================== 223 | // TEST 5: LLM Chain with Multiple Language Models (Error) 224 | // ====================================================================== 225 | 226 | it('should detect multiple language models', async () => { 227 | const languageModel1 = createLanguageModelNode('openai', { 228 | id: 'model-1', 229 | name: 'OpenAI Chat Model 1' 230 | }); 231 | 232 | const languageModel2 = createLanguageModelNode('anthropic', { 233 | id: 'model-2', 234 | name: 'Anthropic Chat Model' 235 | }); 236 | 237 | const llmChain = createBasicLLMChainNode({ 238 | name: 'Basic LLM Chain', 239 | promptType: 'define', 240 | text: 'Test prompt' 241 | }); 242 | 243 | const workflow = createAIWorkflow( 244 | [languageModel1, languageModel2, llmChain], 245 | mergeConnections( 246 | createAIConnection('OpenAI Chat Model 1', 'Basic LLM Chain', 'ai_languageModel'), 247 | createAIConnection('Anthropic Chat Model', 'Basic LLM Chain', 'ai_languageModel') // ERROR: multiple models 248 | ), 249 | { 250 | name: createTestWorkflowName('LLM Chain - Multiple Models'), 251 | tags: ['mcp-integration-test', 'ai-validation'] 252 | } 253 | ); 254 | 255 | const created = await client.createWorkflow(workflow); 256 | context.trackWorkflow(created.id!); 257 | 258 | const response = await handleValidateWorkflow( 259 | { id: created.id }, 260 | repository, 261 | mcpContext 262 | ); 263 | 264 | expect(response.success).toBe(true); 265 | const data = response.data as ValidationResponse; 266 | 267 | expect(data.valid).toBe(false); 268 | expect(data.errors).toBeDefined(); 269 | 270 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 271 | expect(errorCodes).toContain('MULTIPLE_LANGUAGE_MODELS'); 272 | }); 273 | 274 | // ====================================================================== 275 | // TEST 6: LLM Chain with Tools (Error - not supported) 276 | // ====================================================================== 277 | 278 | it('should detect tools connection (not supported)', async () => { 279 | const languageModel = createLanguageModelNode('openai', { 280 | name: 'OpenAI Chat Model' 281 | }); 282 | 283 | // Manually create a tool node 284 | const toolNode: WorkflowNode = { 285 | id: 'tool-1', 286 | name: 'Calculator', 287 | type: '@n8n/n8n-nodes-langchain.toolCalculator', 288 | typeVersion: 1, 289 | position: [250, 400], 290 | parameters: {} 291 | }; 292 | 293 | const llmChain = createBasicLLMChainNode({ 294 | name: 'Basic LLM Chain', 295 | promptType: 'define', 296 | text: 'Calculate something' 297 | }); 298 | 299 | const workflow = createAIWorkflow( 300 | [languageModel, toolNode, llmChain], 301 | mergeConnections( 302 | createAIConnection('OpenAI Chat Model', 'Basic LLM Chain', 'ai_languageModel'), 303 | createAIConnection('Calculator', 'Basic LLM Chain', 'ai_tool') // ERROR: tools not supported 304 | ), 305 | { 306 | name: createTestWorkflowName('LLM Chain - With Tools'), 307 | tags: ['mcp-integration-test', 'ai-validation'] 308 | } 309 | ); 310 | 311 | const created = await client.createWorkflow(workflow); 312 | context.trackWorkflow(created.id!); 313 | 314 | const response = await handleValidateWorkflow( 315 | { id: created.id }, 316 | repository, 317 | mcpContext 318 | ); 319 | 320 | expect(response.success).toBe(true); 321 | const data = response.data as ValidationResponse; 322 | 323 | expect(data.valid).toBe(false); 324 | expect(data.errors).toBeDefined(); 325 | 326 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 327 | expect(errorCodes).toContain('TOOLS_NOT_SUPPORTED'); 328 | 329 | const errorMessages = data.errors!.map(e => e.message).join(' '); 330 | expect(errorMessages).toMatch(/AI Agent/i); // Should suggest using AI Agent 331 | }); 332 | }); 333 | ``` -------------------------------------------------------------------------------- /tests/unit/database/database-adapter-unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi } from 'vitest'; 2 | 3 | // Mock logger 4 | vi.mock('../../../src/utils/logger', () => ({ 5 | logger: { 6 | info: vi.fn(), 7 | warn: vi.fn(), 8 | error: vi.fn(), 9 | debug: vi.fn() 10 | } 11 | })); 12 | 13 | describe('Database Adapter - Unit Tests', () => { 14 | describe('DatabaseAdapter Interface', () => { 15 | it('should define interface when adapter is created', () => { 16 | // This is a type test - ensuring the interface is correctly defined 17 | type DatabaseAdapter = { 18 | prepare: (sql: string) => any; 19 | exec: (sql: string) => void; 20 | close: () => void; 21 | pragma: (key: string, value?: any) => any; 22 | readonly inTransaction: boolean; 23 | transaction: <T>(fn: () => T) => T; 24 | checkFTS5Support: () => boolean; 25 | }; 26 | 27 | // Type assertion to ensure interface matches 28 | const mockAdapter: DatabaseAdapter = { 29 | prepare: vi.fn(), 30 | exec: vi.fn(), 31 | close: vi.fn(), 32 | pragma: vi.fn(), 33 | inTransaction: false, 34 | transaction: vi.fn((fn) => fn()), 35 | checkFTS5Support: vi.fn(() => true) 36 | }; 37 | 38 | expect(mockAdapter).toBeDefined(); 39 | expect(mockAdapter.prepare).toBeDefined(); 40 | expect(mockAdapter.exec).toBeDefined(); 41 | expect(mockAdapter.close).toBeDefined(); 42 | expect(mockAdapter.pragma).toBeDefined(); 43 | expect(mockAdapter.transaction).toBeDefined(); 44 | expect(mockAdapter.checkFTS5Support).toBeDefined(); 45 | }); 46 | }); 47 | 48 | describe('PreparedStatement Interface', () => { 49 | it('should define interface when statement is prepared', () => { 50 | // Type test for PreparedStatement 51 | type PreparedStatement = { 52 | run: (...params: any[]) => { changes: number; lastInsertRowid: number | bigint }; 53 | get: (...params: any[]) => any; 54 | all: (...params: any[]) => any[]; 55 | iterate: (...params: any[]) => IterableIterator<any>; 56 | pluck: (toggle?: boolean) => PreparedStatement; 57 | expand: (toggle?: boolean) => PreparedStatement; 58 | raw: (toggle?: boolean) => PreparedStatement; 59 | columns: () => any[]; 60 | bind: (...params: any[]) => PreparedStatement; 61 | }; 62 | 63 | const mockStmt: PreparedStatement = { 64 | run: vi.fn(() => ({ changes: 1, lastInsertRowid: 1 })), 65 | get: vi.fn(), 66 | all: vi.fn(() => []), 67 | iterate: vi.fn(function* () {}), 68 | pluck: vi.fn(function(this: any) { return this; }), 69 | expand: vi.fn(function(this: any) { return this; }), 70 | raw: vi.fn(function(this: any) { return this; }), 71 | columns: vi.fn(() => []), 72 | bind: vi.fn(function(this: any) { return this; }) 73 | }; 74 | 75 | expect(mockStmt).toBeDefined(); 76 | expect(mockStmt.run).toBeDefined(); 77 | expect(mockStmt.get).toBeDefined(); 78 | expect(mockStmt.all).toBeDefined(); 79 | expect(mockStmt.iterate).toBeDefined(); 80 | expect(mockStmt.pluck).toBeDefined(); 81 | expect(mockStmt.expand).toBeDefined(); 82 | expect(mockStmt.raw).toBeDefined(); 83 | expect(mockStmt.columns).toBeDefined(); 84 | expect(mockStmt.bind).toBeDefined(); 85 | }); 86 | }); 87 | 88 | describe('FTS5 Support Detection', () => { 89 | it('should detect support when FTS5 module is available', () => { 90 | const mockDb = { 91 | exec: vi.fn() 92 | }; 93 | 94 | // Function to test FTS5 support detection logic 95 | const checkFTS5Support = (db: any): boolean => { 96 | try { 97 | db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);"); 98 | db.exec("DROP TABLE IF EXISTS test_fts5;"); 99 | return true; 100 | } catch (error) { 101 | return false; 102 | } 103 | }; 104 | 105 | // Test when FTS5 is supported 106 | expect(checkFTS5Support(mockDb)).toBe(true); 107 | expect(mockDb.exec).toHaveBeenCalledWith( 108 | "CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);" 109 | ); 110 | 111 | // Test when FTS5 is not supported 112 | mockDb.exec.mockImplementation(() => { 113 | throw new Error('no such module: fts5'); 114 | }); 115 | 116 | expect(checkFTS5Support(mockDb)).toBe(false); 117 | }); 118 | }); 119 | 120 | describe('Transaction Handling', () => { 121 | it('should handle commit and rollback when transaction is executed', () => { 122 | // Test transaction wrapper logic 123 | const mockDb = { 124 | exec: vi.fn(), 125 | inTransaction: false 126 | }; 127 | 128 | const transaction = <T>(db: any, fn: () => T): T => { 129 | try { 130 | db.exec('BEGIN'); 131 | db.inTransaction = true; 132 | const result = fn(); 133 | db.exec('COMMIT'); 134 | db.inTransaction = false; 135 | return result; 136 | } catch (error) { 137 | db.exec('ROLLBACK'); 138 | db.inTransaction = false; 139 | throw error; 140 | } 141 | }; 142 | 143 | // Test successful transaction 144 | const result = transaction(mockDb, () => 'success'); 145 | expect(result).toBe('success'); 146 | expect(mockDb.exec).toHaveBeenCalledWith('BEGIN'); 147 | expect(mockDb.exec).toHaveBeenCalledWith('COMMIT'); 148 | expect(mockDb.inTransaction).toBe(false); 149 | 150 | // Reset mocks 151 | mockDb.exec.mockClear(); 152 | 153 | // Test failed transaction 154 | expect(() => { 155 | transaction(mockDb, () => { 156 | throw new Error('transaction error'); 157 | }); 158 | }).toThrow('transaction error'); 159 | 160 | expect(mockDb.exec).toHaveBeenCalledWith('BEGIN'); 161 | expect(mockDb.exec).toHaveBeenCalledWith('ROLLBACK'); 162 | expect(mockDb.inTransaction).toBe(false); 163 | }); 164 | }); 165 | 166 | describe('Pragma Handling', () => { 167 | it('should return values when pragma commands are executed', () => { 168 | const mockDb = { 169 | pragma: vi.fn((key: string, value?: any) => { 170 | if (key === 'journal_mode' && value === 'WAL') { 171 | return 'wal'; 172 | } 173 | return null; 174 | }) 175 | }; 176 | 177 | expect(mockDb.pragma('journal_mode', 'WAL')).toBe('wal'); 178 | expect(mockDb.pragma('other_key')).toBe(null); 179 | }); 180 | }); 181 | 182 | describe('SQLJSAdapter Save Behavior (Memory Leak Fix - Issue #330)', () => { 183 | it('should use default 5000ms save interval when env var not set', () => { 184 | // Verify default interval is 5000ms (not old 100ms) 185 | const DEFAULT_INTERVAL = 5000; 186 | expect(DEFAULT_INTERVAL).toBe(5000); 187 | }); 188 | 189 | it('should use custom save interval from SQLJS_SAVE_INTERVAL_MS env var', () => { 190 | // Mock environment variable 191 | const originalEnv = process.env.SQLJS_SAVE_INTERVAL_MS; 192 | process.env.SQLJS_SAVE_INTERVAL_MS = '10000'; 193 | 194 | // Test that interval would be parsed 195 | const envInterval = process.env.SQLJS_SAVE_INTERVAL_MS; 196 | const parsedInterval = envInterval ? parseInt(envInterval, 10) : 5000; 197 | 198 | expect(parsedInterval).toBe(10000); 199 | 200 | // Restore environment 201 | if (originalEnv !== undefined) { 202 | process.env.SQLJS_SAVE_INTERVAL_MS = originalEnv; 203 | } else { 204 | delete process.env.SQLJS_SAVE_INTERVAL_MS; 205 | } 206 | }); 207 | 208 | it('should fall back to default when invalid env var is provided', () => { 209 | // Test validation logic 210 | const testCases = [ 211 | { input: 'invalid', expected: 5000 }, 212 | { input: '50', expected: 5000 }, // Too low (< 100) 213 | { input: '-100', expected: 5000 }, // Negative 214 | { input: '0', expected: 5000 }, // Zero 215 | ]; 216 | 217 | testCases.forEach(({ input, expected }) => { 218 | const parsed = parseInt(input, 10); 219 | const interval = (isNaN(parsed) || parsed < 100) ? 5000 : parsed; 220 | expect(interval).toBe(expected); 221 | }); 222 | }); 223 | 224 | it('should debounce multiple rapid saves using configured interval', () => { 225 | // Test debounce logic 226 | let timer: NodeJS.Timeout | null = null; 227 | const mockSave = vi.fn(); 228 | 229 | const scheduleSave = (interval: number) => { 230 | if (timer) { 231 | clearTimeout(timer); 232 | } 233 | timer = setTimeout(() => { 234 | mockSave(); 235 | }, interval); 236 | }; 237 | 238 | // Simulate rapid operations 239 | scheduleSave(5000); 240 | scheduleSave(5000); 241 | scheduleSave(5000); 242 | 243 | // Should only schedule once (debounced) 244 | expect(mockSave).not.toHaveBeenCalled(); 245 | 246 | // Cleanup 247 | if (timer) clearTimeout(timer); 248 | }); 249 | }); 250 | 251 | describe('SQLJSAdapter Memory Optimization', () => { 252 | it('should not use Buffer.from() copy in saveToFile()', () => { 253 | // Test that direct Uint8Array write logic is correct 254 | const mockData = new Uint8Array([1, 2, 3, 4, 5]); 255 | 256 | // Verify Uint8Array can be used directly 257 | expect(mockData).toBeInstanceOf(Uint8Array); 258 | expect(mockData.length).toBe(5); 259 | 260 | // This test verifies the pattern used in saveToFile() 261 | // The actual implementation writes mockData directly to fsSync.writeFileSync() 262 | // without using Buffer.from(mockData) which would double memory usage 263 | }); 264 | 265 | it('should cleanup resources with explicit null assignment', () => { 266 | // Test cleanup pattern used in saveToFile() 267 | let data: Uint8Array | null = new Uint8Array([1, 2, 3]); 268 | 269 | try { 270 | // Simulate save operation 271 | expect(data).not.toBeNull(); 272 | } finally { 273 | // Explicit cleanup helps GC 274 | data = null; 275 | } 276 | 277 | expect(data).toBeNull(); 278 | }); 279 | 280 | it('should handle save errors without leaking resources', () => { 281 | // Test error handling with cleanup 282 | let data: Uint8Array | null = null; 283 | let errorThrown = false; 284 | 285 | try { 286 | data = new Uint8Array([1, 2, 3]); 287 | // Simulate error 288 | throw new Error('Save failed'); 289 | } catch (error) { 290 | errorThrown = true; 291 | } finally { 292 | // Cleanup happens even on error 293 | data = null; 294 | } 295 | 296 | expect(errorThrown).toBe(true); 297 | expect(data).toBeNull(); 298 | }); 299 | }); 300 | 301 | describe('Read vs Write Operation Handling', () => { 302 | it('should not trigger save on read-only prepare() calls', () => { 303 | // Test that prepare() doesn't schedule save 304 | // Only exec() and SQLJSStatement.run() should trigger saves 305 | 306 | const mockScheduleSave = vi.fn(); 307 | 308 | // Simulate prepare() - should NOT call scheduleSave 309 | // prepare() just creates statement, doesn't modify DB 310 | 311 | // Simulate exec() - SHOULD call scheduleSave 312 | mockScheduleSave(); 313 | 314 | expect(mockScheduleSave).toHaveBeenCalledTimes(1); 315 | }); 316 | 317 | it('should trigger save on write operations (INSERT/UPDATE/DELETE)', () => { 318 | const mockScheduleSave = vi.fn(); 319 | 320 | // Simulate write operations 321 | mockScheduleSave(); // INSERT 322 | mockScheduleSave(); // UPDATE 323 | mockScheduleSave(); // DELETE 324 | 325 | expect(mockScheduleSave).toHaveBeenCalledTimes(3); 326 | }); 327 | }); 328 | }); ``` -------------------------------------------------------------------------------- /src/utils/cache-utils.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Cache utilities for flexible instance configuration 3 | * Provides hash creation, metrics tracking, and cache configuration 4 | */ 5 | 6 | import { createHash } from 'crypto'; 7 | import { LRUCache } from 'lru-cache'; 8 | import { logger } from './logger'; 9 | 10 | /** 11 | * Cache metrics for monitoring and optimization 12 | */ 13 | export interface CacheMetrics { 14 | hits: number; 15 | misses: number; 16 | evictions: number; 17 | sets: number; 18 | deletes: number; 19 | clears: number; 20 | size: number; 21 | maxSize: number; 22 | avgHitRate: number; 23 | createdAt: Date; 24 | lastResetAt: Date; 25 | } 26 | 27 | /** 28 | * Cache configuration options 29 | */ 30 | export interface CacheConfig { 31 | max: number; 32 | ttlMinutes: number; 33 | } 34 | 35 | /** 36 | * Simple memoization cache for hash results 37 | * Limited size to prevent memory growth 38 | */ 39 | const hashMemoCache = new Map<string, string>(); 40 | const MAX_MEMO_SIZE = 1000; 41 | 42 | /** 43 | * Metrics tracking for cache operations 44 | */ 45 | class CacheMetricsTracker { 46 | private metrics!: CacheMetrics; 47 | private startTime: Date; 48 | 49 | constructor() { 50 | this.startTime = new Date(); 51 | this.reset(); 52 | } 53 | 54 | /** 55 | * Reset all metrics to initial state 56 | */ 57 | reset(): void { 58 | this.metrics = { 59 | hits: 0, 60 | misses: 0, 61 | evictions: 0, 62 | sets: 0, 63 | deletes: 0, 64 | clears: 0, 65 | size: 0, 66 | maxSize: 0, 67 | avgHitRate: 0, 68 | createdAt: this.startTime, 69 | lastResetAt: new Date() 70 | }; 71 | } 72 | 73 | /** 74 | * Record a cache hit 75 | */ 76 | recordHit(): void { 77 | this.metrics.hits++; 78 | this.updateHitRate(); 79 | } 80 | 81 | /** 82 | * Record a cache miss 83 | */ 84 | recordMiss(): void { 85 | this.metrics.misses++; 86 | this.updateHitRate(); 87 | } 88 | 89 | /** 90 | * Record a cache eviction 91 | */ 92 | recordEviction(): void { 93 | this.metrics.evictions++; 94 | } 95 | 96 | /** 97 | * Record a cache set operation 98 | */ 99 | recordSet(): void { 100 | this.metrics.sets++; 101 | } 102 | 103 | /** 104 | * Record a cache delete operation 105 | */ 106 | recordDelete(): void { 107 | this.metrics.deletes++; 108 | } 109 | 110 | /** 111 | * Record a cache clear operation 112 | */ 113 | recordClear(): void { 114 | this.metrics.clears++; 115 | } 116 | 117 | /** 118 | * Update cache size metrics 119 | */ 120 | updateSize(current: number, max: number): void { 121 | this.metrics.size = current; 122 | this.metrics.maxSize = max; 123 | } 124 | 125 | /** 126 | * Update average hit rate 127 | */ 128 | private updateHitRate(): void { 129 | const total = this.metrics.hits + this.metrics.misses; 130 | if (total > 0) { 131 | this.metrics.avgHitRate = this.metrics.hits / total; 132 | } 133 | } 134 | 135 | /** 136 | * Get current metrics snapshot 137 | */ 138 | getMetrics(): CacheMetrics { 139 | return { ...this.metrics }; 140 | } 141 | 142 | /** 143 | * Get formatted metrics for logging 144 | */ 145 | getFormattedMetrics(): string { 146 | const { hits, misses, evictions, avgHitRate, size, maxSize } = this.metrics; 147 | return `Cache Metrics: Hits=${hits}, Misses=${misses}, HitRate=${(avgHitRate * 100).toFixed(2)}%, Size=${size}/${maxSize}, Evictions=${evictions}`; 148 | } 149 | } 150 | 151 | // Global metrics tracker instance 152 | export const cacheMetrics = new CacheMetricsTracker(); 153 | 154 | /** 155 | * Get cache configuration from environment variables or defaults 156 | * @returns Cache configuration with max size and TTL 157 | */ 158 | export function getCacheConfig(): CacheConfig { 159 | const max = parseInt(process.env.INSTANCE_CACHE_MAX || '100', 10); 160 | const ttlMinutes = parseInt(process.env.INSTANCE_CACHE_TTL_MINUTES || '30', 10); 161 | 162 | // Validate configuration bounds 163 | const validatedMax = Math.max(1, Math.min(10000, max)) || 100; 164 | const validatedTtl = Math.max(1, Math.min(1440, ttlMinutes)) || 30; // Max 24 hours 165 | 166 | if (validatedMax !== max || validatedTtl !== ttlMinutes) { 167 | logger.warn('Cache configuration adjusted to valid bounds', { 168 | requestedMax: max, 169 | requestedTtl: ttlMinutes, 170 | actualMax: validatedMax, 171 | actualTtl: validatedTtl 172 | }); 173 | } 174 | 175 | return { 176 | max: validatedMax, 177 | ttlMinutes: validatedTtl 178 | }; 179 | } 180 | 181 | /** 182 | * Create a secure hash for cache key with memoization 183 | * @param input - The input string to hash 184 | * @returns SHA-256 hash as hex string 185 | */ 186 | export function createCacheKey(input: string): string { 187 | // Check memoization cache first 188 | if (hashMemoCache.has(input)) { 189 | return hashMemoCache.get(input)!; 190 | } 191 | 192 | // Create hash 193 | const hash = createHash('sha256').update(input).digest('hex'); 194 | 195 | // Add to memoization cache with size limit 196 | if (hashMemoCache.size >= MAX_MEMO_SIZE) { 197 | // Remove oldest entries (simple FIFO) 198 | const firstKey = hashMemoCache.keys().next().value; 199 | if (firstKey) { 200 | hashMemoCache.delete(firstKey); 201 | } 202 | } 203 | hashMemoCache.set(input, hash); 204 | 205 | return hash; 206 | } 207 | 208 | /** 209 | * Create LRU cache with metrics tracking 210 | * @param onDispose - Optional callback for when items are evicted 211 | * @returns Configured LRU cache instance 212 | */ 213 | export function createInstanceCache<T extends {}>( 214 | onDispose?: (value: T, key: string) => void 215 | ): LRUCache<string, T> { 216 | const config = getCacheConfig(); 217 | 218 | return new LRUCache<string, T>({ 219 | max: config.max, 220 | ttl: config.ttlMinutes * 60 * 1000, // Convert to milliseconds 221 | updateAgeOnGet: true, 222 | dispose: (value, key) => { 223 | cacheMetrics.recordEviction(); 224 | if (onDispose) { 225 | onDispose(value, key); 226 | } 227 | logger.debug('Cache eviction', { 228 | cacheKey: key.substring(0, 8) + '...', 229 | metrics: cacheMetrics.getFormattedMetrics() 230 | }); 231 | } 232 | }); 233 | } 234 | 235 | /** 236 | * Mutex implementation for cache operations 237 | * Prevents race conditions during concurrent access 238 | */ 239 | export class CacheMutex { 240 | private locks: Map<string, Promise<void>> = new Map(); 241 | private lockTimeouts: Map<string, NodeJS.Timeout> = new Map(); 242 | private readonly timeout: number = 5000; // 5 second timeout 243 | 244 | /** 245 | * Acquire a lock for the given key 246 | * @param key - The cache key to lock 247 | * @returns Promise that resolves when lock is acquired 248 | */ 249 | async acquire(key: string): Promise<() => void> { 250 | while (this.locks.has(key)) { 251 | try { 252 | await this.locks.get(key); 253 | } catch { 254 | // Previous lock failed, we can proceed 255 | } 256 | } 257 | 258 | let releaseLock: () => void; 259 | const lockPromise = new Promise<void>((resolve) => { 260 | releaseLock = () => { 261 | resolve(); 262 | this.locks.delete(key); 263 | const timeout = this.lockTimeouts.get(key); 264 | if (timeout) { 265 | clearTimeout(timeout); 266 | this.lockTimeouts.delete(key); 267 | } 268 | }; 269 | }); 270 | 271 | this.locks.set(key, lockPromise); 272 | 273 | // Set timeout to prevent stuck locks 274 | const timeout = setTimeout(() => { 275 | logger.warn('Cache lock timeout, forcefully releasing', { key: key.substring(0, 8) + '...' }); 276 | releaseLock!(); 277 | }, this.timeout); 278 | this.lockTimeouts.set(key, timeout); 279 | 280 | return releaseLock!; 281 | } 282 | 283 | /** 284 | * Check if a key is currently locked 285 | * @param key - The cache key to check 286 | * @returns True if the key is locked 287 | */ 288 | isLocked(key: string): boolean { 289 | return this.locks.has(key); 290 | } 291 | 292 | /** 293 | * Clear all locks (use with caution) 294 | */ 295 | clearAll(): void { 296 | this.lockTimeouts.forEach(timeout => clearTimeout(timeout)); 297 | this.locks.clear(); 298 | this.lockTimeouts.clear(); 299 | } 300 | } 301 | 302 | /** 303 | * Retry configuration for API operations 304 | */ 305 | export interface RetryConfig { 306 | maxAttempts: number; 307 | baseDelayMs: number; 308 | maxDelayMs: number; 309 | jitterFactor: number; 310 | } 311 | 312 | /** 313 | * Default retry configuration 314 | */ 315 | export const DEFAULT_RETRY_CONFIG: RetryConfig = { 316 | maxAttempts: 3, 317 | baseDelayMs: 1000, 318 | maxDelayMs: 10000, 319 | jitterFactor: 0.3 320 | }; 321 | 322 | /** 323 | * Calculate exponential backoff delay with jitter 324 | * @param attempt - Current attempt number (0-based) 325 | * @param config - Retry configuration 326 | * @returns Delay in milliseconds 327 | */ 328 | export function calculateBackoffDelay(attempt: number, config: RetryConfig = DEFAULT_RETRY_CONFIG): number { 329 | const exponentialDelay = Math.min( 330 | config.baseDelayMs * Math.pow(2, attempt), 331 | config.maxDelayMs 332 | ); 333 | 334 | // Add jitter to prevent thundering herd 335 | const jitter = exponentialDelay * config.jitterFactor * Math.random(); 336 | 337 | return Math.floor(exponentialDelay + jitter); 338 | } 339 | 340 | /** 341 | * Execute function with retry logic 342 | * @param fn - Function to execute 343 | * @param config - Retry configuration 344 | * @param context - Optional context for logging 345 | * @returns Result of the function 346 | */ 347 | export async function withRetry<T>( 348 | fn: () => Promise<T>, 349 | config: RetryConfig = DEFAULT_RETRY_CONFIG, 350 | context?: string 351 | ): Promise<T> { 352 | let lastError: Error; 353 | 354 | for (let attempt = 0; attempt < config.maxAttempts; attempt++) { 355 | try { 356 | return await fn(); 357 | } catch (error) { 358 | lastError = error as Error; 359 | 360 | // Check if error is retryable 361 | if (!isRetryableError(error)) { 362 | throw error; 363 | } 364 | 365 | if (attempt < config.maxAttempts - 1) { 366 | const delay = calculateBackoffDelay(attempt, config); 367 | logger.debug('Retrying operation after delay', { 368 | context, 369 | attempt: attempt + 1, 370 | maxAttempts: config.maxAttempts, 371 | delayMs: delay, 372 | error: lastError.message 373 | }); 374 | await new Promise(resolve => setTimeout(resolve, delay)); 375 | } 376 | } 377 | } 378 | 379 | logger.error('All retry attempts exhausted', { 380 | context, 381 | attempts: config.maxAttempts, 382 | lastError: lastError!.message 383 | }); 384 | 385 | throw lastError!; 386 | } 387 | 388 | /** 389 | * Check if an error is retryable 390 | * @param error - The error to check 391 | * @returns True if the error is retryable 392 | */ 393 | function isRetryableError(error: any): boolean { 394 | // Network errors 395 | if (error.code === 'ECONNREFUSED' || 396 | error.code === 'ECONNRESET' || 397 | error.code === 'ETIMEDOUT' || 398 | error.code === 'ENOTFOUND') { 399 | return true; 400 | } 401 | 402 | // HTTP status codes that are retryable 403 | if (error.response?.status) { 404 | const status = error.response.status; 405 | return status === 429 || // Too Many Requests 406 | status === 503 || // Service Unavailable 407 | status === 504 || // Gateway Timeout 408 | (status >= 500 && status < 600); // Server errors 409 | } 410 | 411 | // Timeout errors 412 | if (error.message && error.message.toLowerCase().includes('timeout')) { 413 | return true; 414 | } 415 | 416 | return false; 417 | } 418 | 419 | /** 420 | * Format cache statistics for logging or display 421 | * @returns Formatted statistics string 422 | */ 423 | export function getCacheStatistics(): string { 424 | const metrics = cacheMetrics.getMetrics(); 425 | const runtime = Date.now() - metrics.createdAt.getTime(); 426 | const runtimeMinutes = Math.floor(runtime / 60000); 427 | 428 | return ` 429 | Cache Statistics: 430 | Runtime: ${runtimeMinutes} minutes 431 | Total Operations: ${metrics.hits + metrics.misses} 432 | Hit Rate: ${(metrics.avgHitRate * 100).toFixed(2)}% 433 | Current Size: ${metrics.size}/${metrics.maxSize} 434 | Total Evictions: ${metrics.evictions} 435 | Sets: ${metrics.sets}, Deletes: ${metrics.deletes}, Clears: ${metrics.clears} 436 | `.trim(); 437 | } ``` -------------------------------------------------------------------------------- /tests/setup/test-env.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Test Environment Configuration Loader 3 | * 4 | * This module handles loading and validating test environment variables 5 | * with type safety and default values. 6 | */ 7 | 8 | import * as dotenv from 'dotenv'; 9 | import * as path from 'path'; 10 | import { existsSync } from 'fs'; 11 | 12 | // Load test environment variables 13 | export function loadTestEnvironment(): void { 14 | // CI Debug logging 15 | const isCI = process.env.CI === 'true'; 16 | 17 | // First, load the main .env file (for integration tests that need real credentials) 18 | const mainEnvPath = path.resolve(process.cwd(), '.env'); 19 | if (existsSync(mainEnvPath)) { 20 | dotenv.config({ path: mainEnvPath }); 21 | if (isCI) { 22 | console.log('[CI-DEBUG] Loaded .env file from:', mainEnvPath); 23 | } 24 | } 25 | 26 | // Load base test environment 27 | const testEnvPath = path.resolve(process.cwd(), '.env.test'); 28 | 29 | if (isCI) { 30 | console.log('[CI-DEBUG] Looking for .env.test at:', testEnvPath); 31 | console.log('[CI-DEBUG] File exists?', existsSync(testEnvPath)); 32 | } 33 | 34 | if (existsSync(testEnvPath)) { 35 | // Don't override values from .env 36 | const result = dotenv.config({ path: testEnvPath, override: false }); 37 | if (isCI && result.error) { 38 | console.error('[CI-DEBUG] Failed to load .env.test:', result.error); 39 | } else if (isCI && result.parsed) { 40 | console.log('[CI-DEBUG] Successfully loaded', Object.keys(result.parsed).length, 'env vars from .env.test'); 41 | } 42 | } else if (isCI) { 43 | console.warn('[CI-DEBUG] .env.test file not found, will use defaults only'); 44 | } 45 | 46 | // Load local test overrides (for sensitive values) 47 | const localEnvPath = path.resolve(process.cwd(), '.env.test.local'); 48 | if (existsSync(localEnvPath)) { 49 | dotenv.config({ path: localEnvPath, override: true }); 50 | } 51 | 52 | // Set test-specific defaults (only if not already set) 53 | setTestDefaults(); 54 | 55 | // Validate required environment variables 56 | validateTestEnvironment(); 57 | } 58 | 59 | /** 60 | * Set default values for test environment variables 61 | */ 62 | function setTestDefaults(): void { 63 | // Ensure we're in test mode 64 | process.env.NODE_ENV = 'test'; 65 | process.env.TEST_ENVIRONMENT = 'true'; 66 | 67 | // Set defaults if not already set 68 | const defaults: Record<string, string> = { 69 | // Database 70 | NODE_DB_PATH: ':memory:', 71 | REBUILD_ON_START: 'false', 72 | 73 | // API 74 | N8N_API_URL: 'http://localhost:3001/mock-api', 75 | N8N_API_KEY: 'test-api-key-12345', 76 | 77 | // Server 78 | PORT: '3001', 79 | HOST: '127.0.0.1', 80 | 81 | // Logging 82 | LOG_LEVEL: 'error', 83 | DEBUG: 'false', 84 | TEST_LOG_VERBOSE: 'false', 85 | 86 | // Timeouts 87 | TEST_TIMEOUT_UNIT: '5000', 88 | TEST_TIMEOUT_INTEGRATION: '15000', 89 | TEST_TIMEOUT_E2E: '30000', 90 | TEST_TIMEOUT_GLOBAL: '30000', // Reduced from 60s to 30s to catch hangs faster 91 | 92 | // Test execution 93 | TEST_RETRY_ATTEMPTS: '2', 94 | TEST_RETRY_DELAY: '1000', 95 | TEST_PARALLEL: 'true', 96 | TEST_MAX_WORKERS: '4', 97 | 98 | // Features 99 | FEATURE_MOCK_EXTERNAL_APIS: 'true', 100 | FEATURE_USE_TEST_CONTAINERS: 'false', 101 | MSW_ENABLED: 'true', 102 | MSW_API_DELAY: '0', 103 | 104 | // Paths 105 | TEST_FIXTURES_PATH: './tests/fixtures', 106 | TEST_DATA_PATH: './tests/data', 107 | TEST_SNAPSHOTS_PATH: './tests/__snapshots__', 108 | 109 | // Performance 110 | PERF_THRESHOLD_API_RESPONSE: '100', 111 | PERF_THRESHOLD_DB_QUERY: '50', 112 | PERF_THRESHOLD_NODE_PARSE: '200', 113 | 114 | // Caching 115 | CACHE_TTL: '0', 116 | CACHE_ENABLED: 'false', 117 | 118 | // Rate limiting 119 | RATE_LIMIT_MAX: '0', 120 | RATE_LIMIT_WINDOW: '0', 121 | 122 | // Error handling 123 | ERROR_SHOW_STACK: 'true', 124 | ERROR_SHOW_DETAILS: 'true', 125 | 126 | // Cleanup 127 | TEST_CLEANUP_ENABLED: 'true', 128 | TEST_CLEANUP_ON_FAILURE: 'false', 129 | 130 | // Database seeding 131 | TEST_SEED_DATABASE: 'true', 132 | TEST_SEED_TEMPLATES: 'true', 133 | 134 | // Network 135 | NETWORK_TIMEOUT: '5000', 136 | NETWORK_RETRY_COUNT: '0', 137 | 138 | // Memory 139 | TEST_MEMORY_LIMIT: '512', 140 | 141 | // Coverage 142 | COVERAGE_DIR: './coverage', 143 | COVERAGE_REPORTER: 'lcov,html,text-summary' 144 | }; 145 | 146 | for (const [key, value] of Object.entries(defaults)) { 147 | if (!process.env[key]) { 148 | process.env[key] = value; 149 | } 150 | } 151 | } 152 | 153 | /** 154 | * Validate that required environment variables are set 155 | */ 156 | function validateTestEnvironment(): void { 157 | const required = [ 158 | 'NODE_ENV', 159 | 'NODE_DB_PATH', 160 | 'N8N_API_URL', 161 | 'N8N_API_KEY' 162 | ]; 163 | 164 | const missing = required.filter(key => !process.env[key]); 165 | 166 | if (missing.length > 0) { 167 | throw new Error( 168 | `Missing required test environment variables: ${missing.join(', ')}\n` + 169 | 'Please ensure .env.test is properly configured.' 170 | ); 171 | } 172 | 173 | // Validate NODE_ENV is set to test 174 | if (process.env.NODE_ENV !== 'test') { 175 | throw new Error( 176 | 'NODE_ENV must be set to "test" when running tests.\n' + 177 | 'This prevents accidental execution against production systems.' 178 | ); 179 | } 180 | } 181 | 182 | /** 183 | * Get typed test environment configuration 184 | */ 185 | export function getTestConfig() { 186 | // Ensure defaults are set before accessing 187 | if (!process.env.N8N_API_URL) { 188 | setTestDefaults(); 189 | } 190 | 191 | return { 192 | // Environment 193 | nodeEnv: process.env.NODE_ENV || 'test', 194 | isTest: process.env.TEST_ENVIRONMENT === 'true', 195 | 196 | // Database 197 | database: { 198 | path: process.env.NODE_DB_PATH || ':memory:', 199 | rebuildOnStart: process.env.REBUILD_ON_START === 'true', 200 | seedData: process.env.TEST_SEED_DATABASE === 'true', 201 | seedTemplates: process.env.TEST_SEED_TEMPLATES === 'true' 202 | }, 203 | 204 | // API 205 | api: { 206 | url: process.env.N8N_API_URL || 'http://localhost:3001/mock-api', 207 | key: process.env.N8N_API_KEY || 'test-api-key-12345', 208 | webhookBaseUrl: process.env.N8N_WEBHOOK_BASE_URL, 209 | webhookTestUrl: process.env.N8N_WEBHOOK_TEST_URL 210 | }, 211 | 212 | // Server 213 | server: { 214 | port: parseInt(process.env.PORT || '3001', 10), 215 | host: process.env.HOST || '127.0.0.1', 216 | corsOrigin: process.env.CORS_ORIGIN?.split(',') || [] 217 | }, 218 | 219 | // Authentication 220 | auth: { 221 | token: process.env.AUTH_TOKEN, 222 | mcpToken: process.env.MCP_AUTH_TOKEN 223 | }, 224 | 225 | // Logging 226 | logging: { 227 | level: process.env.LOG_LEVEL || 'error', 228 | debug: process.env.DEBUG === 'true', 229 | verbose: process.env.TEST_LOG_VERBOSE === 'true', 230 | showStack: process.env.ERROR_SHOW_STACK === 'true', 231 | showDetails: process.env.ERROR_SHOW_DETAILS === 'true' 232 | }, 233 | 234 | // Test execution 235 | execution: { 236 | timeouts: { 237 | unit: parseInt(process.env.TEST_TIMEOUT_UNIT || '5000', 10), 238 | integration: parseInt(process.env.TEST_TIMEOUT_INTEGRATION || '15000', 10), 239 | e2e: parseInt(process.env.TEST_TIMEOUT_E2E || '30000', 10), 240 | global: parseInt(process.env.TEST_TIMEOUT_GLOBAL || '60000', 10) 241 | }, 242 | retry: { 243 | attempts: parseInt(process.env.TEST_RETRY_ATTEMPTS || '2', 10), 244 | delay: parseInt(process.env.TEST_RETRY_DELAY || '1000', 10) 245 | }, 246 | parallel: process.env.TEST_PARALLEL === 'true', 247 | maxWorkers: parseInt(process.env.TEST_MAX_WORKERS || '4', 10) 248 | }, 249 | 250 | // Features 251 | features: { 252 | coverage: process.env.FEATURE_TEST_COVERAGE === 'true', 253 | screenshots: process.env.FEATURE_TEST_SCREENSHOTS === 'true', 254 | videos: process.env.FEATURE_TEST_VIDEOS === 'true', 255 | trace: process.env.FEATURE_TEST_TRACE === 'true', 256 | mockExternalApis: process.env.FEATURE_MOCK_EXTERNAL_APIS === 'true', 257 | useTestContainers: process.env.FEATURE_USE_TEST_CONTAINERS === 'true' 258 | }, 259 | 260 | // Mocking 261 | mocking: { 262 | msw: { 263 | enabled: process.env.MSW_ENABLED === 'true', 264 | apiDelay: parseInt(process.env.MSW_API_DELAY || '0', 10) 265 | }, 266 | redis: { 267 | enabled: process.env.REDIS_MOCK_ENABLED === 'true', 268 | port: parseInt(process.env.REDIS_MOCK_PORT || '6380', 10) 269 | }, 270 | elasticsearch: { 271 | enabled: process.env.ELASTICSEARCH_MOCK_ENABLED === 'true', 272 | port: parseInt(process.env.ELASTICSEARCH_MOCK_PORT || '9201', 10) 273 | } 274 | }, 275 | 276 | // Paths 277 | paths: { 278 | fixtures: process.env.TEST_FIXTURES_PATH || './tests/fixtures', 279 | data: process.env.TEST_DATA_PATH || './tests/data', 280 | snapshots: process.env.TEST_SNAPSHOTS_PATH || './tests/__snapshots__' 281 | }, 282 | 283 | // Performance 284 | performance: { 285 | thresholds: { 286 | apiResponse: parseInt(process.env.PERF_THRESHOLD_API_RESPONSE || '100', 10), 287 | dbQuery: parseInt(process.env.PERF_THRESHOLD_DB_QUERY || '50', 10), 288 | nodeParse: parseInt(process.env.PERF_THRESHOLD_NODE_PARSE || '200', 10) 289 | } 290 | }, 291 | 292 | // Rate limiting 293 | rateLimiting: { 294 | max: parseInt(process.env.RATE_LIMIT_MAX || '0', 10), 295 | window: parseInt(process.env.RATE_LIMIT_WINDOW || '0', 10) 296 | }, 297 | 298 | // Caching 299 | cache: { 300 | enabled: process.env.CACHE_ENABLED === 'true', 301 | ttl: parseInt(process.env.CACHE_TTL || '0', 10) 302 | }, 303 | 304 | // Cleanup 305 | cleanup: { 306 | enabled: process.env.TEST_CLEANUP_ENABLED === 'true', 307 | onFailure: process.env.TEST_CLEANUP_ON_FAILURE === 'true' 308 | }, 309 | 310 | // Network 311 | network: { 312 | timeout: parseInt(process.env.NETWORK_TIMEOUT || '5000', 10), 313 | retryCount: parseInt(process.env.NETWORK_RETRY_COUNT || '0', 10) 314 | }, 315 | 316 | // Memory 317 | memory: { 318 | limit: parseInt(process.env.TEST_MEMORY_LIMIT || '512', 10) 319 | }, 320 | 321 | // Coverage 322 | coverage: { 323 | dir: process.env.COVERAGE_DIR || './coverage', 324 | reporters: (process.env.COVERAGE_REPORTER || 'lcov,html,text-summary').split(',') 325 | } 326 | }; 327 | } 328 | 329 | // Export type for the test configuration 330 | export type TestConfig = ReturnType<typeof getTestConfig>; 331 | 332 | /** 333 | * Helper to check if we're in test mode 334 | */ 335 | export function isTestMode(): boolean { 336 | return process.env.NODE_ENV === 'test' || process.env.TEST_ENVIRONMENT === 'true'; 337 | } 338 | 339 | /** 340 | * Helper to get timeout for specific test type 341 | */ 342 | export function getTestTimeout(type: 'unit' | 'integration' | 'e2e' | 'global' = 'unit'): number { 343 | const config = getTestConfig(); 344 | return config.execution.timeouts[type]; 345 | } 346 | 347 | /** 348 | * Helper to check if a feature is enabled 349 | */ 350 | export function isFeatureEnabled(feature: keyof TestConfig['features']): boolean { 351 | const config = getTestConfig(); 352 | return config.features[feature]; 353 | } 354 | 355 | /** 356 | * Reset environment to defaults (useful for test isolation) 357 | */ 358 | export function resetTestEnvironment(): void { 359 | // Clear all test-specific environment variables 360 | const testKeys = Object.keys(process.env).filter(key => 361 | key.startsWith('TEST_') || 362 | key.startsWith('FEATURE_') || 363 | key.startsWith('MSW_') || 364 | key.startsWith('PERF_') 365 | ); 366 | 367 | testKeys.forEach(key => { 368 | delete process.env[key]; 369 | }); 370 | 371 | // Reload defaults 372 | loadTestEnvironment(); 373 | } ``` -------------------------------------------------------------------------------- /docs/RAILWAY_DEPLOYMENT.md: -------------------------------------------------------------------------------- ```markdown 1 | # Railway Deployment Guide for n8n-MCP 2 | 3 | Deploy n8n-MCP to Railway's cloud platform with zero configuration and connect it to Claude Desktop from anywhere. 4 | 5 | ## 🚀 Quick Deploy 6 | 7 | Deploy n8n-MCP with one click: 8 | 9 | [](https://railway.com/deploy/VY6UOG?referralCode=n8n-mcp) 10 | 11 | ## 📋 Overview 12 | 13 | Railway deployment provides: 14 | - ☁️ **Instant cloud hosting** - No server setup required 15 | - 🔒 **Secure by default** - HTTPS included, auth token warnings 16 | - 🌐 **Global access** - Connect from any Claude Desktop 17 | - ⚡ **Auto-scaling** - Railway handles the infrastructure 18 | - 📊 **Built-in monitoring** - Logs and metrics included 19 | 20 | ## 🎯 Step-by-Step Deployment 21 | 22 | ### 1. Deploy to Railway 23 | 24 | 1. **Click the Deploy button** above 25 | 2. **Sign in to Railway** (or create account) 26 | 3. **Configure your deployment**: 27 | - Project name (optional) 28 | - Environment (leave as "production") 29 | - Region (choose closest to you) 30 | 4. **Click "Deploy"** and wait ~2-3 minutes 31 | 32 | ### 2. Configure Security 33 | 34 | **IMPORTANT**: The deployment includes a default AUTH_TOKEN for instant functionality, but you MUST change it: 35 | 36 |  37 | 38 | 1. **Go to your Railway dashboard** 39 | 2. **Click on your n8n-mcp service** 40 | 3. **Navigate to "Variables" tab** 41 | 4. **Find `AUTH_TOKEN`** 42 | 5. **Replace with secure token**: 43 | ```bash 44 | # Generate secure token locally: 45 | openssl rand -base64 32 46 | ``` 47 | 6. **Railway will automatically redeploy** with the new token 48 | 49 | > ⚠️ **Security Warning**: The server displays warnings every 5 minutes until you change the default token! 50 | 51 | ### 3. Get Your Service URL 52 | 53 |  54 | 55 | 1. In Railway dashboard, click on your service 56 | 2. Go to **"Settings"** tab 57 | 3. Under **"Domains"**, you'll see your URL: 58 | ``` 59 | https://your-app-name.up.railway.app 60 | ``` 61 | 4. Copy this URL for Claude Desktop configuration and add /mcp at the end 62 | 63 | ### 4. Connect Claude Desktop 64 | 65 | Add to your Claude Desktop configuration: 66 | 67 | ```json 68 | { 69 | "mcpServers": { 70 | "n8n-railway": { 71 | "command": "npx", 72 | "args": [ 73 | "-y", 74 | "mcp-remote", 75 | "https://your-app-name.up.railway.app/mcp", 76 | "--header", 77 | "Authorization: Bearer YOUR_SECURE_TOKEN_HERE" 78 | ] 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | **Configuration file locations:** 85 | - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` 86 | - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` 87 | - **Linux**: `~/.config/Claude/claude_desktop_config.json` 88 | 89 | **Restart Claude Desktop** after saving the configuration. 90 | 91 | ## 🔧 Environment Variables 92 | 93 | ### Default Variables (Pre-configured) 94 | 95 | These are automatically set by the Railway template: 96 | 97 | | Variable | Default Value | Description | 98 | |----------|--------------|-------------| 99 | | `AUTH_TOKEN` | `REPLACE_THIS...` | **⚠️ CHANGE IMMEDIATELY** | 100 | | `MCP_MODE` | `http` | Required for cloud deployment | 101 | | `USE_FIXED_HTTP` | `true` | Stable HTTP implementation | 102 | | `NODE_ENV` | `production` | Production optimizations | 103 | | `LOG_LEVEL` | `info` | Balanced logging | 104 | | `TRUST_PROXY` | `1` | Railway runs behind proxy | 105 | | `CORS_ORIGIN` | `*` | Allow any origin | 106 | | `HOST` | `0.0.0.0` | Listen on all interfaces | 107 | | `PORT` | (Railway provides) | Don't set manually | 108 | | `AUTH_RATE_LIMIT_WINDOW` | `900000` (15 min) | Rate limit window (v2.16.3+) | 109 | | `AUTH_RATE_LIMIT_MAX` | `20` | Max auth attempts (v2.16.3+) | 110 | | `WEBHOOK_SECURITY_MODE` | `strict` | SSRF protection mode (v2.16.3+) | 111 | 112 | ### Optional Variables 113 | 114 | | Variable | Default Value | Description | 115 | |----------|--------------|-------------| 116 | | `N8N_MODE` | `false` | Enable n8n integration mode for MCP Client Tool | 117 | | `N8N_API_URL` | - | URL of your n8n instance (for workflow management) | 118 | | `N8N_API_KEY` | - | API key from n8n Settings → API | 119 | 120 | ### Optional: n8n Integration 121 | 122 | #### For n8n MCP Client Tool Integration 123 | 124 | To use n8n-MCP with n8n's MCP Client Tool node: 125 | 126 | 1. **Go to Railway dashboard** → Your service → **Variables** 127 | 2. **Add this variable**: 128 | - `N8N_MODE`: Set to `true` to enable n8n integration mode 129 | 3. **Save changes** - Railway will redeploy automatically 130 | 131 | #### For n8n API Integration (Workflow Management) 132 | 133 | To enable workflow management features: 134 | 135 | 1. **Go to Railway dashboard** → Your service → **Variables** 136 | 2. **Add these variables**: 137 | - `N8N_API_URL`: Your n8n instance URL (e.g., `https://n8n.example.com`) 138 | - `N8N_API_KEY`: API key from n8n Settings → API 139 | 3. **Save changes** - Railway will redeploy automatically 140 | 141 | ## 🏗️ Architecture Details 142 | 143 | ### How It Works 144 | 145 | ``` 146 | Claude Desktop → mcp-remote → Railway (HTTPS) → n8n-MCP Server 147 | ``` 148 | 149 | 1. **Claude Desktop** uses `mcp-remote` as a bridge 150 | 2. **mcp-remote** converts stdio to HTTP requests 151 | 3. **Railway** provides HTTPS endpoint and infrastructure 152 | 4. **n8n-MCP** runs in HTTP mode on Railway 153 | 154 | ### Single-Instance Design 155 | 156 | **Important**: The n8n-MCP HTTP server is designed for single n8n instance deployment: 157 | - n8n API credentials are configured server-side via environment variables 158 | - All clients connecting to the server share the same n8n instance 159 | - For multi-tenant usage, deploy separate Railway instances 160 | 161 | ### Security Model 162 | 163 | - **Bearer Token Authentication**: All requests require the AUTH_TOKEN 164 | - **HTTPS by Default**: Railway provides SSL certificates 165 | - **Environment Isolation**: Each deployment is isolated 166 | - **No State Storage**: Server is stateless (database is read-only) 167 | 168 | ## 🚨 Troubleshooting 169 | 170 | ### Connection Issues 171 | 172 | **"Invalid URL" error in Claude Desktop:** 173 | - Ensure you're using the exact configuration format shown above 174 | - Don't add "connect" or other arguments before the URL 175 | - The URL should end with `/mcp` 176 | 177 | **"Unauthorized" error:** 178 | - Check that your AUTH_TOKEN matches exactly (no extra spaces) 179 | - Ensure the Authorization header format is correct: `Authorization: Bearer TOKEN` 180 | 181 | **"Cannot connect to server":** 182 | - Verify your Railway deployment is running (check Railway dashboard) 183 | - Ensure the URL is correct and includes `https://` 184 | - Check Railway logs for any errors 185 | 186 | **Windows: "The filename, directory name, or volume label syntax is incorrect" or npx command not found:** 187 | 188 | This is a common Windows issue with spaces in Node.js installation paths. The error occurs because Claude Desktop can't properly execute npx. 189 | 190 | **Solution 1: Use node directly (Recommended)** 191 | ```json 192 | { 193 | "mcpServers": { 194 | "n8n-railway": { 195 | "command": "node", 196 | "args": [ 197 | "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js", 198 | "-y", 199 | "mcp-remote", 200 | "https://your-app-name.up.railway.app/mcp", 201 | "--header", 202 | "Authorization: Bearer YOUR_SECURE_TOKEN_HERE" 203 | ] 204 | } 205 | } 206 | } 207 | ``` 208 | 209 | **Solution 2: Use cmd wrapper** 210 | ```json 211 | { 212 | "mcpServers": { 213 | "n8n-railway": { 214 | "command": "cmd", 215 | "args": [ 216 | "/C", 217 | "\"C:\\Program Files\\nodejs\\npx\" -y mcp-remote https://your-app-name.up.railway.app/mcp --header \"Authorization: Bearer YOUR_SECURE_TOKEN_HERE\"" 218 | ] 219 | } 220 | } 221 | } 222 | ``` 223 | 224 | To find your exact npx path, open Command Prompt and run: `where npx` 225 | 226 | ### Railway-Specific Issues 227 | 228 | **Build failures:** 229 | - Railway uses AMD64 architecture - the template is configured for this 230 | - Check build logs in Railway dashboard for specific errors 231 | 232 | **Environment variable issues:** 233 | - Variables are case-sensitive 234 | - Don't include quotes in the Railway dashboard (only in JSON config) 235 | - Railway automatically restarts when you change variables 236 | 237 | **Domain not working:** 238 | - It may take 1-2 minutes for the domain to become active 239 | - Check the "Deployments" tab to ensure the latest deployment succeeded 240 | 241 | ## 📊 Monitoring & Logs 242 | 243 | ### View Logs 244 | 245 | 1. Go to Railway dashboard 246 | 2. Click on your n8n-mcp service 247 | 3. Click on **"Logs"** tab 248 | 4. You'll see real-time logs including: 249 | - Server startup messages 250 | - Authentication attempts 251 | - API requests (without sensitive data) 252 | - Any errors or warnings 253 | 254 | ### Monitor Usage 255 | 256 | Railway provides metrics for: 257 | - **Memory usage** (typically ~100-200MB) 258 | - **CPU usage** (minimal when idle) 259 | - **Network traffic** 260 | - **Response times** 261 | 262 | ## 💰 Pricing & Limits 263 | 264 | ### Railway Free Tier 265 | - **$5 free credit** monthly 266 | - **500 hours** of runtime 267 | - **Sufficient for personal use** of n8n-MCP 268 | 269 | ### Estimated Costs 270 | - **n8n-MCP typically uses**: ~0.1 GB RAM 271 | - **Monthly cost**: ~$2-3 for 24/7 operation 272 | - **Well within free tier** for most users 273 | 274 | ## 🔄 Updates & Maintenance 275 | 276 | ### Manual Updates 277 | 278 | Since the Railway template uses a specific Docker image tag, updates are manual: 279 | 280 | 1. **Check for updates** on [GitHub](https://github.com/czlonkowski/n8n-mcp) 281 | 2. **Update image tag** in Railway: 282 | - Go to Settings → Deploy → Docker Image 283 | - Change tag from current to new version 284 | - Click "Redeploy" 285 | 286 | ### Automatic Updates (Not Recommended) 287 | 288 | You could use the `latest` tag, but this may cause unexpected breaking changes. 289 | 290 | ## 🔒 Security Features (v2.16.3+) 291 | 292 | Railway deployments include enhanced security features: 293 | 294 | ### Rate Limiting 295 | - **Automatic brute force protection** - 20 attempts per 15 minutes per IP 296 | - **Configurable limits** via `AUTH_RATE_LIMIT_WINDOW` and `AUTH_RATE_LIMIT_MAX` 297 | - **Standard rate limit headers** for client awareness 298 | 299 | ### SSRF Protection 300 | - **Default strict mode** blocks localhost, private IPs, and cloud metadata 301 | - **Cloud metadata always blocked** (169.254.169.254, metadata.google.internal, etc.) 302 | - **Use `moderate` mode only if** connecting to local n8n instance 303 | 304 | **Security Configuration:** 305 | ```bash 306 | # In Railway Variables tab: 307 | WEBHOOK_SECURITY_MODE=strict # Production (recommended) 308 | # or 309 | WEBHOOK_SECURITY_MODE=moderate # If using local n8n with port forwarding 310 | 311 | # Rate limiting (defaults are good for most use cases) 312 | AUTH_RATE_LIMIT_WINDOW=900000 # 15 minutes 313 | AUTH_RATE_LIMIT_MAX=20 # 20 attempts per IP 314 | ``` 315 | 316 | ## 📝 Best Practices 317 | 318 | 1. **Always change the default AUTH_TOKEN immediately** 319 | 2. **Use strong, unique tokens** (32+ characters) 320 | 3. **Monitor logs** for unauthorized access attempts 321 | 4. **Keep credentials secure** - never commit them to git 322 | 5. **Use environment variables** for all sensitive data 323 | 6. **Regular updates** - check for new versions monthly 324 | 325 | ## 🆘 Getting Help 326 | 327 | - **Railway Documentation**: [docs.railway.app](https://docs.railway.app) 328 | - **n8n-MCP Issues**: [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues) 329 | - **Railway Community**: [Discord](https://discord.gg/railway) 330 | 331 | ## 🎉 Success! 332 | 333 | Once connected, you can use all n8n-MCP features from Claude Desktop: 334 | - Search and explore 500+ n8n nodes 335 | - Get node configurations and examples 336 | - Validate workflows before deployment 337 | - Manage n8n workflows (if API configured) 338 | 339 | The cloud deployment means you can access your n8n knowledge base from any computer with Claude Desktop installed! ```