This is page 11 of 45. Use http://codebase.md/czlonkowski/n8n-mcp?lines=false&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /tests/integration/msw-setup.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { mswTestServer, n8nApiMock, testDataBuilders, integrationTestServer } from './setup/msw-test-server'; import { http, HttpResponse } from 'msw'; import axios from 'axios'; import { server } from './setup/integration-setup'; describe('MSW Setup Verification', () => { const baseUrl = 'http://localhost:5678'; describe('Global MSW Setup', () => { it('should intercept n8n API requests with default handlers', async () => { // This uses the global MSW setup from vitest.config.ts const response = await axios.get(`${baseUrl}/api/v1/health`); expect(response.status).toBe(200); expect(response.data).toEqual({ status: 'ok', version: '1.103.2', features: { workflows: true, executions: true, credentials: true, webhooks: true, } }); }); it('should allow custom handlers for specific tests', async () => { // Add a custom handler just for this test using the global server server.use( http.get('*/api/v1/custom-endpoint', () => { return HttpResponse.json({ custom: true }); }) ); const response = await axios.get(`${baseUrl}/api/v1/custom-endpoint`); expect(response.status).toBe(200); expect(response.data).toEqual({ custom: true }); }); it('should return mock workflows', async () => { const response = await axios.get(`${baseUrl}/api/v1/workflows`); expect(response.status).toBe(200); expect(response.data).toHaveProperty('data'); expect(Array.isArray(response.data.data)).toBe(true); expect(response.data.data.length).toBeGreaterThan(0); }); }); describe('Integration Test Server', () => { // Use the global MSW server instance for these tests afterEach(() => { // Reset handlers after each test to ensure clean state server.resetHandlers(); }); it('should handle workflow creation with custom response', async () => { // Use the global server instance to add custom handler server.use( http.post('*/api/v1/workflows', async ({ request }) => { const body = await request.json() as any; return HttpResponse.json({ data: { id: 'custom-workflow-123', name: 'Test Workflow from MSW', active: body.active || false, nodes: body.nodes, connections: body.connections, settings: body.settings || {}, tags: body.tags || [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), versionId: '1' } }, { status: 201 }); }) ); const workflowData = testDataBuilders.workflow({ name: 'My Test Workflow' }); const response = await axios.post(`${baseUrl}/api/v1/workflows`, workflowData); expect(response.status).toBe(201); expect(response.data.data).toMatchObject({ id: 'custom-workflow-123', name: 'Test Workflow from MSW', nodes: workflowData.nodes, connections: workflowData.connections }); }); it('should handle error responses', async () => { server.use( http.get('*/api/v1/workflows/missing', () => { return HttpResponse.json( { message: 'Workflow not found', code: 'NOT_FOUND', timestamp: new Date().toISOString() }, { status: 404 } ); }) ); try { await axios.get(`${baseUrl}/api/v1/workflows/missing`); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error.response.status).toBe(404); expect(error.response.data).toEqual({ message: 'Workflow not found', code: 'NOT_FOUND', timestamp: expect.any(String) }); } }); it('should simulate rate limiting', async () => { let requestCount = 0; const limit = 5; server.use( http.get('*/api/v1/rate-limited', () => { requestCount++; if (requestCount > limit) { return HttpResponse.json( { message: 'Rate limit exceeded', code: 'RATE_LIMIT', retryAfter: 60 }, { status: 429, headers: { 'X-RateLimit-Limit': String(limit), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(Date.now() + 60000) } } ); } return HttpResponse.json({ success: true }); }) ); // Make requests up to the limit for (let i = 0; i < 5; i++) { const response = await axios.get(`${baseUrl}/api/v1/rate-limited`); expect(response.status).toBe(200); } // Next request should be rate limited try { await axios.get(`${baseUrl}/api/v1/rate-limited`); expect.fail('Should have been rate limited'); } catch (error: any) { expect(error.response.status).toBe(429); expect(error.response.data.code).toBe('RATE_LIMIT'); expect(error.response.headers['x-ratelimit-remaining']).toBe('0'); } }); it('should handle webhook execution', async () => { server.use( http.post('*/webhook/test-webhook', async ({ request }) => { const body = await request.json(); return HttpResponse.json({ processed: true, result: 'success', webhookReceived: { path: 'test-webhook', method: 'POST', body, timestamp: new Date().toISOString() } }); }) ); const webhookData = { message: 'Test webhook payload' }; const response = await axios.post(`${baseUrl}/webhook/test-webhook`, webhookData); expect(response.status).toBe(200); expect(response.data).toMatchObject({ processed: true, result: 'success', webhookReceived: { path: 'test-webhook', method: 'POST', body: webhookData, timestamp: expect.any(String) } }); }); it('should wait for specific requests', async () => { // Since the global server is already handling these endpoints, // we'll just make the requests and verify they succeed const responses = await Promise.all([ axios.get(`${baseUrl}/api/v1/workflows`), axios.get(`${baseUrl}/api/v1/executions`) ]); expect(responses).toHaveLength(2); expect(responses[0].status).toBe(200); expect(responses[0].config.url).toContain('/api/v1/workflows'); expect(responses[1].status).toBe(200); expect(responses[1].config.url).toContain('/api/v1/executions'); }, { timeout: 10000 }); // Increase timeout for this specific test it('should work with scoped handlers', async () => { // First add the scoped handler server.use( http.get('*/api/v1/scoped', () => { return HttpResponse.json({ scoped: true }); }) ); // Make the request while handler is active const response = await axios.get(`${baseUrl}/api/v1/scoped`); expect(response.data).toEqual({ scoped: true }); // Reset handlers to remove the scoped handler server.resetHandlers(); // Verify the scoped handler is no longer active // Since there's no handler for this endpoint now, it should fall through to the catch-all try { await axios.get(`${baseUrl}/api/v1/scoped`); expect.fail('Should have returned 501'); } catch (error: any) { expect(error.response.status).toBe(501); } }); }); describe('Factory Functions', () => { it('should create workflows using factory', async () => { const { workflowFactory } = await import('../mocks/n8n-api/data/workflows'); const simpleWorkflow = workflowFactory.simple('n8n-nodes-base.slack', { resource: 'message', operation: 'post', channel: '#general', text: 'Hello from test' }); expect(simpleWorkflow).toMatchObject({ id: expect.stringMatching(/^workflow_\d+$/), name: 'Test n8n-nodes-base.slack Workflow', // Factory uses nodeType in the name active: true, nodes: expect.arrayContaining([ expect.objectContaining({ type: 'n8n-nodes-base.start' }), expect.objectContaining({ type: 'n8n-nodes-base.slack', parameters: { resource: 'message', operation: 'post', channel: '#general', text: 'Hello from test' } }) ]) }); }); it('should create executions using factory', async () => { const { executionFactory } = await import('../mocks/n8n-api/data/executions'); const successExecution = executionFactory.success('workflow_123'); const errorExecution = executionFactory.error('workflow_456', { message: 'Connection timeout', node: 'http_request_1' }); expect(successExecution).toMatchObject({ workflowId: 'workflow_123', status: 'success', mode: 'manual' }); expect(errorExecution).toMatchObject({ workflowId: 'workflow_456', status: 'error', error: { message: 'Connection timeout', node: 'http_request_1' } }); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/ai-validation/chat-trigger-validation.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Tests: Chat Trigger Validation * * Tests Chat Trigger validation against real n8n instance. */ import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { createTestContext, TestContext, createTestWorkflowName } from '../n8n-api/utils/test-context'; import { getTestN8nClient } from '../n8n-api/utils/n8n-client'; import { N8nApiClient } from '../../../src/services/n8n-api-client'; import { cleanupOrphanedWorkflows } from '../n8n-api/utils/cleanup-helpers'; import { createMcpContext } from '../n8n-api/utils/mcp-context'; import { InstanceContext } from '../../../src/types/instance-context'; import { handleValidateWorkflow } from '../../../src/mcp/handlers-n8n-manager'; import { getNodeRepository, closeNodeRepository } from '../n8n-api/utils/node-repository'; import { NodeRepository } from '../../../src/database/node-repository'; import { ValidationResponse } from '../n8n-api/types/mcp-responses'; import { createChatTriggerNode, createAIAgentNode, createLanguageModelNode, createRespondNode, createAIConnection, createMainConnection, mergeConnections, createAIWorkflow } from './helpers'; import { WorkflowNode } from '../../../src/types/n8n-api'; describe('Integration: Chat Trigger Validation', () => { let context: TestContext; let client: N8nApiClient; let mcpContext: InstanceContext; let repository: NodeRepository; beforeEach(async () => { context = createTestContext(); client = getTestN8nClient(); mcpContext = createMcpContext(); repository = await getNodeRepository(); }); afterEach(async () => { await context.cleanup(); }); afterAll(async () => { await closeNodeRepository(); if (!process.env.CI) { await cleanupOrphanedWorkflows(); } }); // ====================================================================== // TEST 1: Streaming to Non-AI-Agent // ====================================================================== it('should detect streaming to non-AI-Agent', async () => { const chatTrigger = createChatTriggerNode({ name: 'Chat Trigger', responseMode: 'streaming' }); // Regular node (not AI Agent) const regularNode: WorkflowNode = { id: 'set-1', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [450, 300], parameters: { assignments: { assignments: [] } } }; const workflow = createAIWorkflow( [chatTrigger, regularNode], createMainConnection('Chat Trigger', 'Set'), { name: createTestWorkflowName('Chat Trigger - Wrong Target'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('STREAMING_WRONG_TARGET'); const errorMessages = data.errors!.map(e => e.message).join(' '); expect(errorMessages).toMatch(/streaming.*AI Agent/i); }); // ====================================================================== // TEST 2: Missing Connections // ====================================================================== it('should detect missing connections', async () => { const chatTrigger = createChatTriggerNode({ name: 'Chat Trigger' }); const workflow = createAIWorkflow( [chatTrigger], {}, // No connections { name: createTestWorkflowName('Chat Trigger - No Connections'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MISSING_CONNECTIONS'); }); // ====================================================================== // TEST 3: Valid Streaming Setup // ====================================================================== it('should validate valid streaming setup', async () => { const chatTrigger = createChatTriggerNode({ name: 'Chat Trigger', responseMode: 'streaming' }); const languageModel = createLanguageModelNode('openai', { name: 'OpenAI Chat Model' }); const agent = createAIAgentNode({ name: 'AI Agent', text: 'You are a helpful assistant' // No main output connections - streaming mode }); const workflow = createAIWorkflow( [chatTrigger, languageModel, agent], mergeConnections( createMainConnection('Chat Trigger', 'AI Agent'), createAIConnection('OpenAI Chat Model', 'AI Agent', 'ai_languageModel') // NO main output from AI Agent ), { name: createTestWorkflowName('Chat Trigger - Valid Streaming'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(true); expect(data.errors).toBeUndefined(); expect(data.summary.errorCount).toBe(0); }); // ====================================================================== // TEST 4: LastNode Mode (Default) // ====================================================================== it('should validate lastNode mode with AI Agent', async () => { const chatTrigger = createChatTriggerNode({ name: 'Chat Trigger', responseMode: 'lastNode' }); const languageModel = createLanguageModelNode('openai', { name: 'OpenAI Chat Model' }); const agent = createAIAgentNode({ name: 'AI Agent', text: 'You are a helpful assistant' }); const respond = createRespondNode({ name: 'Respond to Webhook' }); const workflow = createAIWorkflow( [chatTrigger, languageModel, agent, respond], mergeConnections( createMainConnection('Chat Trigger', 'AI Agent'), createAIConnection('OpenAI Chat Model', 'AI Agent', 'ai_languageModel'), createMainConnection('AI Agent', 'Respond to Webhook') ), { name: createTestWorkflowName('Chat Trigger - LastNode Mode'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; // Should be valid (lastNode mode allows main output) expect(data.valid).toBe(true); // May have info suggestion about using streaming if (data.info) { const streamingSuggestion = data.info.find((i: any) => i.message.toLowerCase().includes('streaming') ); // This is optional - just checking the suggestion exists if present if (streamingSuggestion) { expect(streamingSuggestion.severity).toBe('info'); } } }); // ====================================================================== // TEST 5: Streaming Agent with Output Connection (Error) // ====================================================================== it('should detect streaming agent with output connection', async () => { const chatTrigger = createChatTriggerNode({ name: 'Chat Trigger', responseMode: 'streaming' }); const languageModel = createLanguageModelNode('openai', { name: 'OpenAI Chat Model' }); const agent = createAIAgentNode({ name: 'AI Agent', text: 'You are a helpful assistant' }); const respond = createRespondNode({ name: 'Respond to Webhook' }); const workflow = createAIWorkflow( [chatTrigger, languageModel, agent, respond], mergeConnections( createMainConnection('Chat Trigger', 'AI Agent'), createAIConnection('OpenAI Chat Model', 'AI Agent', 'ai_languageModel'), createMainConnection('AI Agent', 'Respond to Webhook') // ERROR in streaming mode ), { name: createTestWorkflowName('Chat Trigger - Streaming With Output'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); // Should detect streaming agent has output const streamingErrors = data.errors!.filter(e => { const code = e.details?.code || e.code; return code === 'STREAMING_AGENT_HAS_OUTPUT' || e.message.toLowerCase().includes('streaming'); }); expect(streamingErrors.length).toBeGreaterThan(0); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/flexible-instance-security.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Unit tests for flexible instance configuration security improvements */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { InstanceContext, isInstanceContext, validateInstanceContext } from '../../src/types/instance-context'; import { getN8nApiClient } from '../../src/mcp/handlers-n8n-manager'; import { createHash } from 'crypto'; describe('Flexible Instance Security', () => { beforeEach(() => { // Clear module cache to reset singleton state vi.resetModules(); }); afterEach(() => { vi.clearAllMocks(); }); describe('Input Validation', () => { describe('URL Validation', () => { it('should accept valid HTTP and HTTPS URLs', () => { const validContext: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'valid-key' }; expect(isInstanceContext(validContext)).toBe(true); const httpContext: InstanceContext = { n8nApiUrl: 'http://localhost:5678', n8nApiKey: 'valid-key' }; expect(isInstanceContext(httpContext)).toBe(true); }); it('should reject invalid URL formats', () => { const invalidUrls = [ 'not-a-url', 'ftp://invalid-protocol.com', 'javascript:alert(1)', '//missing-protocol.com', 'https://', '' ]; invalidUrls.forEach(url => { const context = { n8nApiUrl: url, n8nApiKey: 'key' }; const validation = validateInstanceContext(context); expect(validation.valid).toBe(false); expect(validation.errors?.some(error => error.startsWith('Invalid n8nApiUrl:'))).toBe(true); }); }); }); describe('API Key Validation', () => { it('should accept valid API keys', () => { const validKeys = [ 'abc123def456', 'sk_live_abcdefghijklmnop', 'token_1234567890', 'a'.repeat(100) // Long key ]; validKeys.forEach(key => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: key }; const validation = validateInstanceContext(context); expect(validation.valid).toBe(true); }); }); it('should reject placeholder or invalid API keys', () => { const invalidKeys = [ 'YOUR_API_KEY', 'placeholder', 'example', 'YOUR_API_KEY_HERE', 'example-key', 'placeholder-token' ]; invalidKeys.forEach(key => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: key }; const validation = validateInstanceContext(context); expect(validation.valid).toBe(false); expect(validation.errors?.some(error => error.startsWith('Invalid n8nApiKey:'))).toBe(true); }); }); }); describe('Timeout and Retry Validation', () => { it('should validate timeout values', () => { const invalidTimeouts = [0, -1, -1000]; invalidTimeouts.forEach(timeout => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'key', n8nApiTimeout: timeout }; const validation = validateInstanceContext(context); expect(validation.valid).toBe(false); expect(validation.errors?.some(error => error.includes('Must be positive (greater than 0)'))).toBe(true); }); // NaN and Infinity are handled differently const nanContext: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'key', n8nApiTimeout: NaN }; const nanValidation = validateInstanceContext(nanContext); expect(nanValidation.valid).toBe(false); // Valid timeout const validContext: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'key', n8nApiTimeout: 30000 }; const validation = validateInstanceContext(validContext); expect(validation.valid).toBe(true); }); it('should validate retry values', () => { const invalidRetries = [-1, -10]; invalidRetries.forEach(retries => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'key', n8nApiMaxRetries: retries }; const validation = validateInstanceContext(context); expect(validation.valid).toBe(false); expect(validation.errors?.some(error => error.includes('Must be non-negative (0 or greater)'))).toBe(true); }); // Valid retries (including 0) [0, 1, 3, 10].forEach(retries => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'key', n8nApiMaxRetries: retries }; const validation = validateInstanceContext(context); expect(validation.valid).toBe(true); }); }); }); }); describe('Cache Key Security', () => { it('should hash cache keys instead of using raw credentials', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'super-secret-key', instanceId: 'instance-1' }; // Calculate expected hash const expectedHash = createHash('sha256') .update(`${context.n8nApiUrl}:${context.n8nApiKey}:${context.instanceId}`) .digest('hex'); // The actual cache key should be hashed, not contain raw values // We can't directly test the internal cache key, but we can verify // that the function doesn't throw and returns a client const client = getN8nApiClient(context); // If validation passes, client could be created (or null if no env vars) // The important part is that raw credentials aren't exposed expect(() => getN8nApiClient(context)).not.toThrow(); }); it('should not expose API keys in any form', () => { const sensitiveKey = 'super-secret-api-key-12345'; const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: sensitiveKey, instanceId: 'test' }; // Mock console methods to capture any output const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); getN8nApiClient(context); // Verify the sensitive key is never logged const allLogs = [ ...consoleSpy.mock.calls, ...consoleWarnSpy.mock.calls, ...consoleErrorSpy.mock.calls ].flat().join(' '); expect(allLogs).not.toContain(sensitiveKey); consoleSpy.mockRestore(); consoleWarnSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); }); describe('Error Message Sanitization', () => { it('should not expose sensitive data in error messages', () => { const context: InstanceContext = { n8nApiUrl: 'invalid-url', n8nApiKey: 'secret-key-that-should-not-appear', instanceId: 'test-instance' }; const validation = validateInstanceContext(context); // Error messages should be generic, not include actual values expect(validation.errors).toBeDefined(); expect(validation.errors!.join(' ')).not.toContain('secret-key'); expect(validation.errors!.join(' ')).not.toContain(context.n8nApiKey); }); }); describe('Type Guard Security', () => { it('should safely handle malicious input', () => { // Test specific malicious inputs const objectAsUrl = { n8nApiUrl: { toString: () => { throw new Error('XSS'); } } }; expect(() => isInstanceContext(objectAsUrl)).not.toThrow(); expect(isInstanceContext(objectAsUrl)).toBe(false); const arrayAsKey = { n8nApiKey: ['array', 'instead', 'of', 'string'] }; expect(() => isInstanceContext(arrayAsKey)).not.toThrow(); expect(isInstanceContext(arrayAsKey)).toBe(false); // These are actually valid objects with extra properties const protoObj = { __proto__: { isAdmin: true } }; expect(() => isInstanceContext(protoObj)).not.toThrow(); // This is actually a valid object, just has __proto__ property expect(isInstanceContext(protoObj)).toBe(true); const constructorObj = { constructor: { name: 'Evil' } }; expect(() => isInstanceContext(constructorObj)).not.toThrow(); // This is also a valid object with constructor property expect(isInstanceContext(constructorObj)).toBe(true); // Object.create(null) creates an object without prototype const nullProto = Object.create(null); expect(() => isInstanceContext(nullProto)).not.toThrow(); // This is actually a valid empty object, so it passes expect(isInstanceContext(nullProto)).toBe(true); }); it('should handle circular references safely', () => { const circular: any = { n8nApiUrl: 'https://api.n8n.cloud' }; circular.self = circular; expect(() => isInstanceContext(circular)).not.toThrow(); }); }); describe('Memory Management', () => { it('should validate LRU cache configuration', () => { // This is more of a configuration test // In real implementation, we'd test that the cache has proper limits const MAX_CACHE_SIZE = 100; const TTL_MINUTES = 30; // Verify reasonable limits are in place expect(MAX_CACHE_SIZE).toBeLessThanOrEqual(1000); // Not too many expect(TTL_MINUTES).toBeLessThanOrEqual(60); // Not too long }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tool-docs/workflow_management/n8n-get-execution.ts: -------------------------------------------------------------------------------- ```typescript import { ToolDocumentation } from '../types'; export const n8nGetExecutionDoc: ToolDocumentation = { name: 'n8n_get_execution', category: 'workflow_management', essentials: { description: 'Get execution details with smart filtering to avoid token limits. Use preview mode first to assess data size, then fetch appropriately.', keyParameters: ['id', 'mode', 'itemsLimit', 'nodeNames'], example: ` // RECOMMENDED WORKFLOW: // 1. Preview first n8n_get_execution({id: "12345", mode: "preview"}) // Returns: structure, counts, size estimate, recommendation // 2. Based on recommendation, fetch data: n8n_get_execution({id: "12345", mode: "summary"}) // 2 items per node n8n_get_execution({id: "12345", mode: "filtered", itemsLimit: 5}) // 5 items n8n_get_execution({id: "12345", nodeNames: ["HTTP Request"]}) // Specific node `, performance: 'Preview: <50ms, Summary: <200ms, Full: depends on data size', tips: [ 'ALWAYS use preview mode first for large datasets', 'Preview shows structure + counts without consuming tokens for data', 'Summary mode (2 items per node) is safe default', 'Use nodeNames to focus on specific nodes only', 'itemsLimit: 0 = structure only, -1 = unlimited', 'Check recommendation.suggestedMode from preview' ] }, full: { description: `Retrieves and intelligently filters execution data to enable inspection without exceeding token limits. This tool provides multiple modes for different use cases, from quick previews to complete data retrieval. **The Problem**: Workflows processing large datasets (50+ database records) generate execution data that exceeds token/response limits, making traditional full-data fetching impossible. **The Solution**: Four retrieval modes with smart filtering: 1. **Preview**: Structure + counts only (no actual data) 2. **Summary**: 2 sample items per node (safe default) 3. **Filtered**: Custom limits and node selection 4. **Full**: Complete data (use with caution) **Recommended Workflow**: 1. Start with preview mode to assess size 2. Use recommendation to choose appropriate mode 3. Fetch filtered data as needed`, parameters: { id: { type: 'string', required: true, description: 'The execution ID to retrieve. Obtained from list_executions or webhook trigger responses' }, mode: { type: 'string', required: false, description: `Retrieval mode (default: auto-detect from other params): - 'preview': Structure, counts, size estimates - NO actual data (fastest) - 'summary': Metadata + 2 sample items per node (safe default) - 'filtered': Custom filtering with itemsLimit/nodeNames - 'full': Complete execution data (use with caution)` }, nodeNames: { type: 'array', required: false, description: 'Filter to specific nodes by name. Example: ["HTTP Request", "Filter"]. Useful when you only need to inspect specific nodes.' }, itemsLimit: { type: 'number', required: false, description: `Items to return per node (default: 2): - 0: Structure only (see data shape without values) - 1-N: Return N items per node - -1: Unlimited (return all items) Note: Structure-only mode (0) shows JSON schema without actual values.` }, includeInputData: { type: 'boolean', required: false, description: 'Include input data in addition to output data (default: false). Useful for debugging data transformations.' }, includeData: { type: 'boolean', required: false, description: 'DEPRECATED: Legacy parameter. Use mode instead. If true, maps to mode="summary" for backward compatibility.' } }, returns: `**Preview Mode Response**: { mode: 'preview', preview: { totalNodes: number, executedNodes: number, estimatedSizeKB: number, nodes: { [nodeName]: { status: 'success' | 'error', itemCounts: { input: number, output: number }, dataStructure: {...}, // JSON schema estimatedSizeKB: number } } }, recommendation: { canFetchFull: boolean, suggestedMode: 'preview'|'summary'|'filtered'|'full', suggestedItemsLimit?: number, reason: string } } **Summary/Filtered/Full Mode Response**: { mode: 'summary' | 'filtered' | 'full', summary: { totalNodes: number, executedNodes: number, totalItems: number, hasMoreData: boolean // true if truncated }, nodes: { [nodeName]: { executionTime: number, itemsInput: number, itemsOutput: number, status: 'success' | 'error', error?: string, data: { output: [...], // Actual data items metadata: { totalItems: number, itemsShown: number, truncated: boolean } } } } }`, examples: [ `// Example 1: Preview workflow (RECOMMENDED FIRST STEP) n8n_get_execution({id: "exec_123", mode: "preview"}) // Returns structure, counts, size, recommendation // Use this to decide how to fetch data`, `// Example 2: Follow recommendation const preview = n8n_get_execution({id: "exec_123", mode: "preview"}); if (preview.recommendation.canFetchFull) { n8n_get_execution({id: "exec_123", mode: "full"}); } else { n8n_get_execution({ id: "exec_123", mode: "filtered", itemsLimit: preview.recommendation.suggestedItemsLimit }); }`, `// Example 3: Summary mode (safe default for unknown datasets) n8n_get_execution({id: "exec_123", mode: "summary"}) // Gets 2 items per node - safe for most cases`, `// Example 4: Filter to specific node n8n_get_execution({ id: "exec_123", mode: "filtered", nodeNames: ["HTTP Request"], itemsLimit: 5 }) // Gets only HTTP Request node, 5 items`, `// Example 5: Structure only (see data shape) n8n_get_execution({ id: "exec_123", mode: "filtered", itemsLimit: 0 }) // Returns JSON schema without actual values`, `// Example 6: Debug with input data n8n_get_execution({ id: "exec_123", mode: "filtered", nodeNames: ["Transform"], itemsLimit: 2, includeInputData: true }) // See both input and output for debugging`, `// Example 7: Backward compatibility (legacy) n8n_get_execution({id: "exec_123"}) // Minimal data n8n_get_execution({id: "exec_123", includeData: true}) // Maps to summary mode` ], useCases: [ 'Monitor status of triggered workflows', 'Debug failed workflows by examining error messages and partial data', 'Inspect large datasets without exceeding token limits', 'Validate data transformations between nodes', 'Understand execution flow and timing', 'Track workflow performance metrics', 'Verify successful completion before proceeding', 'Extract specific data from execution results' ], performance: `**Response Times** (approximate): - Preview mode: <50ms (no data, just structure) - Summary mode: <200ms (2 items per node) - Filtered mode: 50-500ms (depends on filters) - Full mode: 200ms-5s (depends on data size) **Token Consumption**: - Preview: ~500 tokens (no data values) - Summary (2 items): ~2-5K tokens - Filtered (5 items): ~5-15K tokens - Full (50+ items): 50K+ tokens (may exceed limits) **Optimization Tips**: - Use preview for all large datasets - Use nodeNames to focus on relevant nodes only - Start with small itemsLimit and increase if needed - Use itemsLimit: 0 to see structure without data`, bestPractices: [ 'ALWAYS use preview mode first for unknown datasets', 'Trust the recommendation.suggestedMode from preview', 'Use nodeNames to filter to relevant nodes only', 'Start with summary mode if preview indicates moderate size', 'Use itemsLimit: 0 to understand data structure', 'Check hasMoreData to know if results are truncated', 'Store execution IDs from triggers for later inspection', 'Use mode="filtered" with custom limits for large datasets', 'Include input data only when debugging transformations', 'Monitor summary.totalItems to understand dataset size' ], pitfalls: [ 'DON\'T fetch full mode without previewing first - may timeout', 'DON\'T assume all data fits - always check hasMoreData', 'DON\'T ignore the recommendation from preview mode', 'Execution data is retained based on n8n settings - old executions may be purged', 'Binary data (files, images) is not fully included - only metadata', 'Status "waiting" indicates execution is still running', 'Error executions may have partial data from successful nodes', 'Very large individual items (>1MB) may be truncated', 'Preview mode estimates may be off by 10-20% for complex structures', 'Node names are case-sensitive in nodeNames filter' ], modeComparison: `**When to use each mode**: **Preview**: - ALWAYS use first for unknown datasets - When you need to know if data is safe to fetch - To see data structure without consuming tokens - To get size estimates and recommendations **Summary** (default): - Safe default for most cases - When you need representative samples - When preview recommends it - For quick data inspection **Filtered**: - When you need specific nodes only - When you need more than 2 items but not all - When preview recommends it with itemsLimit - For targeted data extraction **Full**: - ONLY when preview says canFetchFull: true - For small executions (< 20 items total) - When you genuinely need all data - When you're certain data fits in token limit`, relatedTools: [ 'n8n_list_executions - Find execution IDs', 'n8n_trigger_webhook_workflow - Trigger and get execution ID', 'n8n_delete_execution - Clean up old executions', 'n8n_get_workflow - Get workflow structure', 'validate_workflow - Validate before executing' ] } }; ``` -------------------------------------------------------------------------------- /docs/local/TEMPLATE_MINING_ANALYSIS.md: -------------------------------------------------------------------------------- ```markdown # Template Mining Analysis - Alternative to P0-R3 **Date**: 2025-10-02 **Context**: Analyzing whether to fix `get_node_for_task` (28% failure rate) or replace it with template-based configuration extraction ## Executive Summary **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. ## Key Findings ### 1. Template Database Coverage - **Total Templates**: 2,646 production workflows from n8n.io - **Unique Node Types**: 543 (covers 103% of our 525 core nodes) - **Metadata Coverage**: 100% (AI-generated structured metadata) ### 2. Node Type Coverage in Templates Top node types by template usage: ``` 3,820 templates: n8n-nodes-base.httpRequest (144% of total templates!) 3,678 templates: n8n-nodes-base.set 2,445 templates: n8n-nodes-base.code 1,700 templates: n8n-nodes-base.googleSheets 1,471 templates: @n8n/n8n-nodes-langchain.agent 1,269 templates: @n8n/n8n-nodes-langchain.lmChatOpenAi 792 templates: n8n-nodes-base.telegram 702 templates: n8n-nodes-base.httpRequestTool 596 templates: n8n-nodes-base.gmail 466 templates: n8n-nodes-base.webhook ``` **Comparison**: - Hardcoded task templates: 31 tasks covering 5.9% of nodes - Real templates: 2,646 templates with 2-3k examples for common nodes ### 3. Database Structure ```sql CREATE TABLE templates ( id INTEGER PRIMARY KEY, workflow_id INTEGER UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT, -- Node information nodes_used TEXT, -- JSON array: ["n8n-nodes-base.httpRequest", ...] workflow_json_compressed TEXT, -- Base64 encoded gzip of full workflow -- Metadata (100% coverage) metadata_json TEXT, -- AI-generated structured metadata -- Stats views INTEGER DEFAULT 0, created_at DATETIME, -- ... ); ``` ### 4. Real Configuration Examples #### HTTP Request Node Configurations **Simple URL fetch**: ```json { "url": "https://api.example.com/data", "options": {} } ``` **With authentication**: ```json { "url": "=https://api.wavespeed.ai/api/v3/predictions/{{ $json.data.id }}/result", "options": {}, "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth" } ``` **Complex expressions**: ```json { "url": "=https://image.pollinations.ai/prompt/{{$('Social Media Content Factory').item.json.output.description.replaceAll(' ','-').replaceAll(',','').replaceAll('.','') }}", "options": {} } ``` #### Webhook Node Configurations **Basic webhook**: ```json { "path": "ytube", "options": {}, "httpMethod": "POST", "responseMode": "responseNode" } ``` **With binary data**: ```json { "path": "your-endpoint", "options": { "binaryPropertyName": "data" }, "httpMethod": "POST" } ``` ### 5. AI-Generated Metadata Each template has structured metadata including: ```json { "categories": ["automation", "integration", "data processing"], "complexity": "medium", "use_cases": [ "Extract transaction data from Gmail", "Automate bookkeeping", "Expense tracking" ], "estimated_setup_minutes": 30, "required_services": ["Gmail", "Google Sheets", "Google Gemini"], "key_features": [ "Fetch emails by label", "Extract transaction data", "Use LLM for structured output" ], "target_audience": ["Accountants", "Small business owners"] } ``` ## Comparison: Task Templates vs Real Templates ### Current Approach (get_node_for_task) **Pros**: - Curated configurations with best practices - Predictable, stable responses - Fast lookup (no decompression needed) **Cons**: - Only 31 tasks (5.9% node coverage) - 28% failure rate (users can't find what they need) - Requires manual maintenance - Static configurations without real-world context - Usage ratio 22.5:1 (search_nodes is preferred) ### Template-Based Approach **Pros**: - 2,646 real workflows with 2-3k examples for common nodes - 100% metadata coverage for semantic matching - Real-world patterns and best practices - Covers 543 node types (103% coverage) - Self-updating (templates fetched from n8n.io) - Rich context (use cases, complexity, setup time) **Cons**: - Requires decompression for full workflow access - May contain template-specific context (but can be filtered) - Need ranking/filtering logic for best matches ## Proposed Implementation Strategy ### Phase 1: Extract Node Configurations from Templates Create a new service: `TemplateConfigExtractor` ```typescript interface ExtractedNodeConfig { nodeType: string; configuration: Record<string, any>; source: { templateId: number; templateName: string; templateViews: number; useCases: string[]; complexity: 'simple' | 'medium' | 'complex'; }; patterns: { hasAuthentication: boolean; hasExpressions: boolean; hasOptionalFields: boolean; }; } class TemplateConfigExtractor { async extractConfigsForNode( nodeType: string, options?: { complexity?: 'simple' | 'medium' | 'complex'; requiresAuth?: boolean; limit?: number; } ): Promise<ExtractedNodeConfig[]> { // 1. Query templates containing nodeType // 2. Decompress workflow_json_compressed // 3. Extract node configurations // 4. Rank by popularity + complexity match // 5. Return top N configurations } } ``` ### Phase 2: Integrate with Existing Tools **Option A**: Enhance `get_node_essentials` - Add `includeExamples: boolean` parameter - Return 2-3 real configurations from templates - Preserve existing compact format **Option B**: Enhance `get_node_info` - Add `examples` section with template-sourced configs - Include source attribution (template name, views) **Option C**: New tool `get_node_examples` - Dedicated tool for retrieving configuration examples - Query by node type, complexity, use case - Returns ranked list of real configurations ### Phase 3: Deprecate get_node_for_task - Mark as deprecated in tool documentation - Redirect to enhanced tools - Remove after 2-3 version cycles ## Performance Considerations ### Decompression Cost - Average compressed size: 6-12 KB - Decompression time: ~5-10ms per template - Caching strategy needed for frequently accessed templates ### Query Strategy ```sql -- Fast: Get templates for a node type (no decompression) SELECT id, name, views, metadata_json FROM templates WHERE nodes_used LIKE '%n8n-nodes-base.httpRequest%' ORDER BY views DESC LIMIT 10; -- Then decompress only top matches ``` ### Caching - Cache decompressed workflows for popular templates (top 100) - TTL: 1 hour - Estimated memory: 100 * 50KB = 5MB ## Impact on P0-R3 **Original P0-R3 Plan**: Expand task library from 31 to 100+ tasks using fuzzy matching **New Approach**: Mine 2,646 templates for real configurations **Impact Assessment**: | Metric | Original Plan | Template Mining | |--------|--------------|-----------------| | Configuration examples | 100 (estimated) | 2,646+ actual | | Node coverage | ~20% | 103% | | Maintenance | High (manual) | Low (auto-fetch) | | Accuracy | Curated | Production-tested | | Context richness | Limited | Rich metadata | | Development time | 2-3 weeks | 1 week | **Recommendation**: PIVOT to template mining approach for P0-R3 ## Implementation Estimate ### Week 1: Core Infrastructure - Day 1-2: Create `TemplateConfigExtractor` service - Day 3: Implement caching layer - Day 4-5: Testing and optimization ### Week 2: Integration - Day 1-2: Enhance `get_node_essentials` with examples - Day 3: Update tool documentation - Day 4-5: Integration testing **Total**: 2 weeks vs 3 weeks for original plan ## Validation Tests ```typescript // Test: Extract HTTP Request configs const configs = await extractor.extractConfigsForNode( 'n8n-nodes-base.httpRequest', { complexity: 'simple', limit: 5 } ); // Expected: 5 configs from top templates // - Simple URL fetch // - With authentication // - With custom headers // - With expressions // - With error handling // Test: Extract webhook configs const webhookConfigs = await extractor.extractConfigsForNode( 'n8n-nodes-base.webhook', { limit: 3 } ); // Expected: 3 configs showing different patterns // - Basic POST webhook // - With response node // - With binary data handling ``` ## Risks and Mitigation ### Risk 1: Template Quality Varies - **Mitigation**: Filter by views (popularity) and metadata complexity rating - Only use templates with >1000 views for examples ### Risk 2: Decompression Performance - **Mitigation**: Cache decompressed popular templates - Implement lazy loading (decompress on demand) ### Risk 3: Template-Specific Context - **Mitigation**: Extract only node configuration, strip workflow-specific context - Provide source attribution for context ### Risk 4: Breaking Changes in Template Structure - **Mitigation**: Robust error handling in decompression - Fallback to cached configs if template fetch fails ## Success Metrics **Before** (get_node_for_task): - 392 calls, 72% success rate - 28% failure rate - 31 task templates - 5.9% node coverage **Target** (template-based): - 90%+ success rate for configuration discovery - 100%+ node coverage - 2,646+ real-world examples - Self-updating from n8n.io ## Next Steps 1. ✅ Complete template database analysis 2. ⏳ Create `TemplateConfigExtractor` service 3. ⏳ Implement caching layer 4. ⏳ Enhance `get_node_essentials` with examples 5. ⏳ Update P0 implementation plan 6. ⏳ Begin implementation ## Conclusion The template database provides a vastly superior alternative to hardcoded task templates: - **2,646 templates** vs 31 tasks (85x more examples) - **103% node coverage** vs 5.9% coverage (17x improvement) - **Real-world configurations** vs synthetic examples - **Self-updating** vs manual maintenance - **Rich metadata** for semantic matching **Recommendation**: Pivot P0-R3 from "expand task library" to "mine template configurations" ``` -------------------------------------------------------------------------------- /scripts/test-essentials.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env ts-node /** * Test script for validating the get_node_essentials tool * * This script: * 1. Compares get_node_essentials vs get_node_info response sizes * 2. Validates that essential properties are correctly extracted * 3. Checks that examples are properly generated * 4. Tests the property search functionality */ import { N8NDocumentationMCPServer } from '../src/mcp/server'; import { readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; // Color codes for terminal output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m' }; function log(message: string, color: string = colors.reset) { console.log(`${color}${message}${colors.reset}`); } function logSection(title: string) { console.log('\n' + '='.repeat(60)); log(title, colors.bright + colors.cyan); console.log('='.repeat(60)); } function formatBytes(bytes: number): string { if (bytes < 1024) return bytes + ' B'; const kb = bytes / 1024; if (kb < 1024) return kb.toFixed(1) + ' KB'; const mb = kb / 1024; return mb.toFixed(2) + ' MB'; } async function testNodeEssentials(server: N8NDocumentationMCPServer, nodeType: string) { logSection(`Testing ${nodeType}`); try { // Get full node info const startFull = Date.now(); const fullInfo = await server.executeTool('get_node_info', { nodeType }); const fullTime = Date.now() - startFull; const fullSize = JSON.stringify(fullInfo).length; // Get essential info const startEssential = Date.now(); const essentialInfo = await server.executeTool('get_node_essentials', { nodeType }); const essentialTime = Date.now() - startEssential; const essentialSize = JSON.stringify(essentialInfo).length; // Calculate metrics const sizeReduction = ((fullSize - essentialSize) / fullSize * 100).toFixed(1); const speedImprovement = ((fullTime - essentialTime) / fullTime * 100).toFixed(1); // Display results log(`\n📊 Size Comparison:`, colors.bright); log(` Full response: ${formatBytes(fullSize)}`, colors.yellow); log(` Essential response: ${formatBytes(essentialSize)}`, colors.green); log(` Size reduction: ${sizeReduction}% ✨`, colors.bright + colors.green); log(`\n⚡ Performance:`, colors.bright); log(` Full response time: ${fullTime}ms`); log(` Essential response time: ${essentialTime}ms`); log(` Speed improvement: ${speedImprovement}%`, colors.green); log(`\n📋 Property Count:`, colors.bright); const fullPropCount = fullInfo.properties?.length || 0; const essentialPropCount = (essentialInfo.requiredProperties?.length || 0) + (essentialInfo.commonProperties?.length || 0); log(` Full properties: ${fullPropCount}`); log(` Essential properties: ${essentialPropCount}`); log(` Properties removed: ${fullPropCount - essentialPropCount} (${((fullPropCount - essentialPropCount) / fullPropCount * 100).toFixed(1)}%)`, colors.green); log(`\n🔧 Essential Properties:`, colors.bright); log(` Required: ${essentialInfo.requiredProperties?.map((p: any) => p.name).join(', ') || 'None'}`); log(` Common: ${essentialInfo.commonProperties?.map((p: any) => p.name).join(', ') || 'None'}`); log(`\n📚 Examples:`, colors.bright); const examples = Object.keys(essentialInfo.examples || {}); log(` Available examples: ${examples.join(', ') || 'None'}`); if (essentialInfo.examples?.minimal) { log(` Minimal example properties: ${Object.keys(essentialInfo.examples.minimal).join(', ')}`); } log(`\n📊 Metadata:`, colors.bright); log(` Total properties available: ${essentialInfo.metadata?.totalProperties || 0}`); log(` Is AI Tool: ${essentialInfo.metadata?.isAITool ? 'Yes' : 'No'}`); log(` Is Trigger: ${essentialInfo.metadata?.isTrigger ? 'Yes' : 'No'}`); log(` Has Credentials: ${essentialInfo.metadata?.hasCredentials ? 'Yes' : 'No'}`); // Test property search const searchTerms = ['auth', 'header', 'body', 'json']; log(`\n🔍 Property Search Test:`, colors.bright); for (const term of searchTerms) { try { const searchResult = await server.executeTool('search_node_properties', { nodeType, query: term, maxResults: 5 }); log(` "${term}": Found ${searchResult.totalMatches} properties`); } catch (error) { log(` "${term}": Search failed`, colors.red); } } return { nodeType, fullSize, essentialSize, sizeReduction: parseFloat(sizeReduction), fullPropCount, essentialPropCount, success: true }; } catch (error) { log(`❌ Error testing ${nodeType}: ${error}`, colors.red); return { nodeType, fullSize: 0, essentialSize: 0, sizeReduction: 0, fullPropCount: 0, essentialPropCount: 0, success: false, error: error instanceof Error ? error.message : String(error) }; } } async function main() { logSection('n8n MCP Essentials Tool Test Suite'); try { // Initialize server log('\n🚀 Initializing MCP server...', colors.cyan); const server = new N8NDocumentationMCPServer(); // Wait for initialization await new Promise(resolve => setTimeout(resolve, 1000)); // Test nodes const testNodes = [ 'nodes-base.httpRequest', 'nodes-base.webhook', 'nodes-base.code', 'nodes-base.set', 'nodes-base.if', 'nodes-base.postgres', 'nodes-base.openAi', 'nodes-base.googleSheets', 'nodes-base.slack', 'nodes-base.merge' ]; const results = []; for (const nodeType of testNodes) { const result = await testNodeEssentials(server, nodeType); results.push(result); } // Summary logSection('Test Summary'); const successful = results.filter(r => r.success); const totalFullSize = successful.reduce((sum, r) => sum + r.fullSize, 0); const totalEssentialSize = successful.reduce((sum, r) => sum + r.essentialSize, 0); const avgReduction = successful.reduce((sum, r) => sum + r.sizeReduction, 0) / successful.length; log(`\n✅ Successful tests: ${successful.length}/${results.length}`, colors.green); if (successful.length > 0) { log(`\n📊 Overall Statistics:`, colors.bright); log(` Total full size: ${formatBytes(totalFullSize)}`); log(` Total essential size: ${formatBytes(totalEssentialSize)}`); log(` Average reduction: ${avgReduction.toFixed(1)}%`, colors.bright + colors.green); log(`\n🏆 Best Performers:`, colors.bright); const sorted = successful.sort((a, b) => b.sizeReduction - a.sizeReduction); sorted.slice(0, 3).forEach((r, i) => { log(` ${i + 1}. ${r.nodeType}: ${r.sizeReduction}% reduction (${formatBytes(r.fullSize)} → ${formatBytes(r.essentialSize)})`); }); } const failed = results.filter(r => !r.success); if (failed.length > 0) { log(`\n❌ Failed tests:`, colors.red); failed.forEach(r => { log(` - ${r.nodeType}: ${r.error}`, colors.red); }); } // Save detailed results const reportPath = join(process.cwd(), 'test-results-essentials.json'); writeFileSync(reportPath, JSON.stringify({ timestamp: new Date().toISOString(), summary: { totalTests: results.length, successful: successful.length, failed: failed.length, averageReduction: avgReduction, totalFullSize, totalEssentialSize }, results }, null, 2)); log(`\n📄 Detailed results saved to: ${reportPath}`, colors.cyan); // Recommendations logSection('Recommendations'); if (avgReduction > 90) { log('✨ Excellent! The essentials tool is achieving >90% size reduction.', colors.green); } else if (avgReduction > 80) { log('👍 Good! The essentials tool is achieving 80-90% size reduction.', colors.yellow); log(' Consider reviewing nodes with lower reduction rates.'); } else { log('⚠️ The average size reduction is below 80%.', colors.yellow); log(' Review the essential property lists for optimization.'); } // Test specific functionality logSection('Testing Advanced Features'); // Test error handling log('\n🧪 Testing error handling...', colors.cyan); try { await server.executeTool('get_node_essentials', { nodeType: 'non-existent-node' }); log(' ❌ Error handling failed - should have thrown error', colors.red); } catch (error) { log(' ✅ Error handling works correctly', colors.green); } // Test alternative node type formats log('\n🧪 Testing alternative node type formats...', colors.cyan); const alternativeFormats = [ { input: 'httpRequest', expected: 'nodes-base.httpRequest' }, { input: 'nodes-base.httpRequest', expected: 'nodes-base.httpRequest' }, { input: 'HTTPREQUEST', expected: 'nodes-base.httpRequest' } ]; for (const format of alternativeFormats) { try { const result = await server.executeTool('get_node_essentials', { nodeType: format.input }); if (result.nodeType === format.expected) { log(` ✅ "${format.input}" → "${format.expected}"`, colors.green); } else { log(` ❌ "${format.input}" → "${result.nodeType}" (expected "${format.expected}")`, colors.red); } } catch (error) { log(` ❌ "${format.input}" → Error: ${error}`, colors.red); } } log('\n✨ Test suite completed!', colors.bright + colors.green); } catch (error) { log(`\n❌ Fatal error: ${error}`, colors.red); process.exit(1); } } // Run the test main().catch(error => { console.error('Unhandled error:', error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/services/expression-validator.ts: -------------------------------------------------------------------------------- ```typescript /** * Expression Validator for n8n expressions * Validates expression syntax, variable references, and context availability */ interface ExpressionValidationResult { valid: boolean; errors: string[]; warnings: string[]; usedVariables: Set<string>; usedNodes: Set<string>; } interface ExpressionContext { availableNodes: string[]; currentNodeName?: string; isInLoop?: boolean; hasInputData?: boolean; } export class ExpressionValidator { // Common n8n expression patterns private static readonly EXPRESSION_PATTERN = /\{\{([\s\S]+?)\}\}/g; private static readonly VARIABLE_PATTERNS = { json: /\$json(\.[a-zA-Z_][\w]*|\["[^"]+"\]|\['[^']+'\]|\[\d+\])*/g, node: /\$node\["([^"]+)"\]\.json/g, input: /\$input\.item(\.[a-zA-Z_][\w]*|\["[^"]+"\]|\['[^']+'\]|\[\d+\])*/g, items: /\$items\("([^"]+)"(?:,\s*(-?\d+))?\)/g, parameter: /\$parameter\["([^"]+)"\]/g, env: /\$env\.([a-zA-Z_][\w]*)/g, workflow: /\$workflow\.(id|name|active)/g, execution: /\$execution\.(id|mode|resumeUrl)/g, prevNode: /\$prevNode\.(name|outputIndex|runIndex)/g, itemIndex: /\$itemIndex/g, runIndex: /\$runIndex/g, now: /\$now/g, today: /\$today/g, }; /** * Validate a single expression */ static validateExpression( expression: string, context: ExpressionContext ): ExpressionValidationResult { const result: ExpressionValidationResult = { valid: true, errors: [], warnings: [], usedVariables: new Set(), usedNodes: new Set(), }; // Handle null/undefined expression if (!expression) { return result; } // Handle null/undefined context if (!context) { result.valid = false; result.errors.push('Validation context is required'); return result; } // Check for basic syntax errors const syntaxErrors = this.checkSyntaxErrors(expression); result.errors.push(...syntaxErrors); // Extract all expressions const expressions = this.extractExpressions(expression); for (const expr of expressions) { // Validate each expression this.validateSingleExpression(expr, context, result); } // Check for undefined node references this.checkNodeReferences(result, context); result.valid = result.errors.length === 0; return result; } /** * Check for basic syntax errors */ private static checkSyntaxErrors(expression: string): string[] { const errors: string[] = []; // Check for unmatched brackets const openBrackets = (expression.match(/\{\{/g) || []).length; const closeBrackets = (expression.match(/\}\}/g) || []).length; if (openBrackets !== closeBrackets) { errors.push('Unmatched expression brackets {{ }}'); } // Check for nested expressions (not supported in n8n) if (expression.includes('{{') && expression.includes('{{', expression.indexOf('{{') + 2)) { const match = expression.match(/\{\{.*\{\{/); if (match) { errors.push('Nested expressions are not supported'); } } // Check for empty expressions const emptyExpressionPattern = /\{\{\s*\}\}/; if (emptyExpressionPattern.test(expression)) { errors.push('Empty expression found'); } return errors; } /** * Extract all expressions from a string */ private static extractExpressions(text: string): string[] { const expressions: string[] = []; let match; while ((match = this.EXPRESSION_PATTERN.exec(text)) !== null) { expressions.push(match[1].trim()); } return expressions; } /** * Validate a single expression content */ private static validateSingleExpression( expr: string, context: ExpressionContext, result: ExpressionValidationResult ): void { // Check for $json usage let match; const jsonPattern = new RegExp(this.VARIABLE_PATTERNS.json.source, this.VARIABLE_PATTERNS.json.flags); while ((match = jsonPattern.exec(expr)) !== null) { result.usedVariables.add('$json'); if (!context.hasInputData && !context.isInLoop) { result.warnings.push( 'Using $json but node might not have input data' ); } // Check for suspicious property names that might be test/invalid data const fullMatch = match[0]; if (fullMatch.includes('.invalid') || fullMatch.includes('.undefined') || fullMatch.includes('.null') || fullMatch.includes('.test')) { result.warnings.push( `Property access '${fullMatch}' looks suspicious - verify this property exists in your data` ); } } // Check for $node references const nodePattern = new RegExp(this.VARIABLE_PATTERNS.node.source, this.VARIABLE_PATTERNS.node.flags); while ((match = nodePattern.exec(expr)) !== null) { const nodeName = match[1]; result.usedNodes.add(nodeName); result.usedVariables.add('$node'); } // Check for $input usage const inputPattern = new RegExp(this.VARIABLE_PATTERNS.input.source, this.VARIABLE_PATTERNS.input.flags); while ((match = inputPattern.exec(expr)) !== null) { result.usedVariables.add('$input'); if (!context.hasInputData) { result.warnings.push( '$input is only available when the node has input data' ); } } // Check for $items usage const itemsPattern = new RegExp(this.VARIABLE_PATTERNS.items.source, this.VARIABLE_PATTERNS.items.flags); while ((match = itemsPattern.exec(expr)) !== null) { const nodeName = match[1]; result.usedNodes.add(nodeName); result.usedVariables.add('$items'); } // Check for other variables for (const [varName, pattern] of Object.entries(this.VARIABLE_PATTERNS)) { if (['json', 'node', 'input', 'items'].includes(varName)) continue; const testPattern = new RegExp(pattern.source, pattern.flags); if (testPattern.test(expr)) { result.usedVariables.add(`$${varName}`); } } // Check for common mistakes this.checkCommonMistakes(expr, result); } /** * Check for common expression mistakes */ private static checkCommonMistakes( expr: string, result: ExpressionValidationResult ): void { // Check for missing $ prefix - but exclude cases where $ is already present const missingPrefixPattern = /(?<!\$)\b(json|node|input|items|workflow|execution)\b(?!\s*:)/; if (expr.match(missingPrefixPattern)) { result.warnings.push( 'Possible missing $ prefix for variable (e.g., use $json instead of json)' ); } // Check for incorrect array access if (expr.includes('$json[') && !expr.match(/\$json\[\d+\]/)) { result.warnings.push( 'Array access should use numeric index: $json[0] or property access: $json.property' ); } // Check for Python-style property access if (expr.match(/\$json\['[^']+'\]/)) { result.warnings.push( "Consider using dot notation: $json.property instead of $json['property']" ); } // Check for undefined/null access attempts if (expr.match(/\?\./)) { result.warnings.push( 'Optional chaining (?.) is not supported in n8n expressions' ); } // Check for template literals if (expr.includes('${')) { result.errors.push( 'Template literals ${} are not supported. Use string concatenation instead' ); } } /** * Check that all referenced nodes exist */ private static checkNodeReferences( result: ExpressionValidationResult, context: ExpressionContext ): void { for (const nodeName of result.usedNodes) { if (!context.availableNodes.includes(nodeName)) { result.errors.push( `Referenced node "${nodeName}" not found in workflow` ); } } } /** * Validate all expressions in a node's parameters */ static validateNodeExpressions( parameters: any, context: ExpressionContext ): ExpressionValidationResult { const combinedResult: ExpressionValidationResult = { valid: true, errors: [], warnings: [], usedVariables: new Set(), usedNodes: new Set(), }; const visited = new WeakSet(); this.validateParametersRecursive(parameters, context, combinedResult, '', visited); combinedResult.valid = combinedResult.errors.length === 0; return combinedResult; } /** * Recursively validate expressions in parameters */ private static validateParametersRecursive( obj: any, context: ExpressionContext, result: ExpressionValidationResult, path: string = '', visited: WeakSet<object> = new WeakSet() ): void { // Handle circular references if (obj && typeof obj === 'object') { if (visited.has(obj)) { return; // Skip already visited objects } visited.add(obj); } if (typeof obj === 'string') { if (obj.includes('{{')) { const validation = this.validateExpression(obj, context); // Add path context to errors validation.errors.forEach(error => { result.errors.push(path ? `${path}: ${error}` : error); }); validation.warnings.forEach(warning => { result.warnings.push(path ? `${path}: ${warning}` : warning); }); // Merge used variables and nodes validation.usedVariables.forEach(v => result.usedVariables.add(v)); validation.usedNodes.forEach(n => result.usedNodes.add(n)); } } else if (Array.isArray(obj)) { obj.forEach((item, index) => { this.validateParametersRecursive( item, context, result, `${path}[${index}]`, visited ); }); } else if (obj && typeof obj === 'object') { Object.entries(obj).forEach(([key, value]) => { const newPath = path ? `${path}.${key}` : key; this.validateParametersRecursive(value, context, result, newPath, visited); }); } } } ``` -------------------------------------------------------------------------------- /src/mcp/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { N8NDocumentationMCPServer } from './server'; import { logger } from '../utils/logger'; import { TelemetryConfigManager } from '../telemetry/config-manager'; import { EarlyErrorLogger } from '../telemetry/early-error-logger'; import { STARTUP_CHECKPOINTS, findFailedCheckpoint, StartupCheckpoint } from '../telemetry/startup-checkpoints'; import { existsSync } from 'fs'; // Add error details to stderr for Claude Desktop debugging process.on('uncaughtException', (error) => { if (process.env.MCP_MODE !== 'stdio') { console.error('Uncaught Exception:', error); } logger.error('Uncaught Exception:', error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { if (process.env.MCP_MODE !== 'stdio') { console.error('Unhandled Rejection at:', promise, 'reason:', reason); } logger.error('Unhandled Rejection:', reason); process.exit(1); }); /** * Detects if running in a container environment (Docker, Podman, Kubernetes, etc.) * Uses multiple detection methods for robustness: * 1. Environment variables (IS_DOCKER, IS_CONTAINER with multiple formats) * 2. Filesystem markers (/.dockerenv, /run/.containerenv) */ function isContainerEnvironment(): boolean { // Check environment variables with multiple truthy formats const dockerEnv = (process.env.IS_DOCKER || '').toLowerCase(); const containerEnv = (process.env.IS_CONTAINER || '').toLowerCase(); if (['true', '1', 'yes'].includes(dockerEnv)) { return true; } if (['true', '1', 'yes'].includes(containerEnv)) { return true; } // Fallback: Check filesystem markers // /.dockerenv exists in Docker containers // /run/.containerenv exists in Podman containers try { return existsSync('/.dockerenv') || existsSync('/run/.containerenv'); } catch (error) { // If filesystem check fails, assume not in container logger.debug('Container detection filesystem check failed:', error); return false; } } async function main() { // Initialize early error logger for pre-handshake error capture (v2.18.3) // Now using singleton pattern with defensive initialization const startTime = Date.now(); const earlyLogger = EarlyErrorLogger.getInstance(); const checkpoints: StartupCheckpoint[] = []; try { // Checkpoint: Process started (fire-and-forget, no await) earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED); checkpoints.push(STARTUP_CHECKPOINTS.PROCESS_STARTED); // Handle telemetry CLI commands const args = process.argv.slice(2); if (args.length > 0 && args[0] === 'telemetry') { const telemetryConfig = TelemetryConfigManager.getInstance(); const action = args[1]; switch (action) { case 'enable': telemetryConfig.enable(); process.exit(0); break; case 'disable': telemetryConfig.disable(); process.exit(0); break; case 'status': console.log(telemetryConfig.getStatus()); process.exit(0); break; default: console.log(` Usage: n8n-mcp telemetry [command] Commands: enable Enable anonymous telemetry disable Disable anonymous telemetry status Show current telemetry status Learn more: https://github.com/czlonkowski/n8n-mcp/blob/main/PRIVACY.md `); process.exit(args[1] ? 1 : 0); } } const mode = process.env.MCP_MODE || 'stdio'; // Checkpoint: Telemetry initializing (fire-and-forget, no await) earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.TELEMETRY_INITIALIZING); checkpoints.push(STARTUP_CHECKPOINTS.TELEMETRY_INITIALIZING); // Telemetry is already initialized by TelemetryConfigManager in imports // Mark as ready (fire-and-forget, no await) earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.TELEMETRY_READY); checkpoints.push(STARTUP_CHECKPOINTS.TELEMETRY_READY); try { // Only show debug messages in HTTP mode to avoid corrupting stdio communication if (mode === 'http') { console.error(`Starting n8n Documentation MCP Server in ${mode} mode...`); console.error('Current directory:', process.cwd()); console.error('Node version:', process.version); } // Checkpoint: MCP handshake starting (fire-and-forget, no await) earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_STARTING); checkpoints.push(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_STARTING); if (mode === 'http') { // Check if we should use the fixed implementation if (process.env.USE_FIXED_HTTP === 'true') { // Use the fixed HTTP implementation that bypasses StreamableHTTPServerTransport issues const { startFixedHTTPServer } = await import('../http-server'); await startFixedHTTPServer(); } else { // HTTP mode - for remote deployment with single-session architecture const { SingleSessionHTTPServer } = await import('../http-server-single-session'); const server = new SingleSessionHTTPServer(); // Graceful shutdown handlers const shutdown = async () => { await server.shutdown(); process.exit(0); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); await server.start(); } } else { // Stdio mode - for local Claude Desktop const server = new N8NDocumentationMCPServer(undefined, earlyLogger); // Graceful shutdown handler (fixes Issue #277) let isShuttingDown = false; const shutdown = async (signal: string = 'UNKNOWN') => { if (isShuttingDown) return; // Prevent multiple shutdown calls isShuttingDown = true; try { logger.info(`Shutdown initiated by: ${signal}`); await server.shutdown(); // Close stdin to signal we're done reading if (process.stdin && !process.stdin.destroyed) { process.stdin.pause(); process.stdin.destroy(); } // Exit with timeout to ensure we don't hang // Increased to 1000ms for slower systems setTimeout(() => { logger.warn('Shutdown timeout exceeded, forcing exit'); process.exit(0); }, 1000).unref(); // Let the timeout handle the exit for graceful shutdown // (removed immediate exit to allow cleanup to complete) } catch (error) { logger.error('Error during shutdown:', error); process.exit(1); } }; // Handle termination signals (fixes Issue #277) // Signal handling strategy: // - Claude Desktop (Windows/macOS/Linux): stdin handlers + signal handlers // Primary: stdin close when Claude quits | Fallback: SIGTERM/SIGINT/SIGHUP // - Container environments: signal handlers ONLY // stdin closed in detached mode would trigger immediate shutdown // Container detection via IS_DOCKER/IS_CONTAINER env vars + filesystem markers // - Manual execution: Both stdin and signal handlers work process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGHUP', () => shutdown('SIGHUP')); // Handle stdio disconnect - PRIMARY shutdown mechanism for Claude Desktop // Skip in container environments (Docker, Kubernetes, Podman) to prevent // premature shutdown when stdin is closed in detached mode. // Containers rely on signal handlers (SIGTERM/SIGINT/SIGHUP) for proper shutdown. const isContainer = isContainerEnvironment(); if (!isContainer && process.stdin.readable && !process.stdin.destroyed) { try { process.stdin.on('end', () => shutdown('STDIN_END')); process.stdin.on('close', () => shutdown('STDIN_CLOSE')); } catch (error) { logger.error('Failed to register stdin handlers, using signal handlers only:', error); // Continue - signal handlers will still work } } await server.run(); } // Checkpoint: MCP handshake complete (fire-and-forget, no await) earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_COMPLETE); checkpoints.push(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_COMPLETE); // Checkpoint: Server ready (fire-and-forget, no await) earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.SERVER_READY); checkpoints.push(STARTUP_CHECKPOINTS.SERVER_READY); // Log successful startup (fire-and-forget, no await) const startupDuration = Date.now() - startTime; earlyLogger.logStartupSuccess(checkpoints, startupDuration); logger.info(`Server startup completed in ${startupDuration}ms (${checkpoints.length} checkpoints passed)`); } catch (error) { // Log startup error with checkpoint context (fire-and-forget, no await) const failedCheckpoint = findFailedCheckpoint(checkpoints); earlyLogger.logStartupError(failedCheckpoint, error); // In stdio mode, we cannot output to console at all if (mode !== 'stdio') { console.error('Failed to start MCP server:', error); logger.error('Failed to start MCP server', error); // Provide helpful error messages if (error instanceof Error && error.message.includes('nodes.db not found')) { console.error('\nTo fix this issue:'); console.error('1. cd to the n8n-mcp directory'); console.error('2. Run: npm run build'); console.error('3. Run: npm run rebuild'); } else if (error instanceof Error && error.message.includes('NODE_MODULE_VERSION')) { console.error('\nTo fix this Node.js version mismatch:'); console.error('1. cd to the n8n-mcp directory'); console.error('2. Run: npm rebuild better-sqlite3'); console.error('3. If that doesn\'t work, try: rm -rf node_modules && npm install'); } } process.exit(1); } } catch (outerError) { // Outer error catch for early initialization failures logger.error('Critical startup error:', outerError); process.exit(1); } } // Run if called directly if (require.main === module) { main().catch(console.error); } ``` -------------------------------------------------------------------------------- /docs/DOCKER_TROUBLESHOOTING.md: -------------------------------------------------------------------------------- ```markdown # Docker Troubleshooting Guide This guide helps resolve common issues when running n8n-mcp with Docker, especially when connecting to n8n instances. ## Table of Contents - [Common Issues](#common-issues) - [502 Bad Gateway Errors](#502-bad-gateway-errors) - [Custom Database Path Not Working](#custom-database-path-not-working-v27160) - [Container Name Conflicts](#container-name-conflicts) - [n8n API Connection Issues](#n8n-api-connection-issues) - [Docker Networking](#docker-networking) - [Quick Solutions](#quick-solutions) - [Debugging Steps](#debugging-steps) ## Common Issues ### Docker Configuration File Not Working (v2.8.2+) **Symptoms:** - Config file mounted but environment variables not set - Container starts but ignores configuration - Getting "permission denied" errors **Solutions:** 1. **Ensure file is mounted correctly:** ```bash # Correct - mount as read-only docker run -v $(pwd)/config.json:/app/config.json:ro ... # Check if file is accessible docker exec n8n-mcp cat /app/config.json ``` 2. **Verify JSON syntax:** ```bash # Validate JSON file cat config.json | jq . ``` 3. **Check Docker logs for parsing errors:** ```bash docker logs n8n-mcp | grep -i config ``` 4. **Common issues:** - Invalid JSON syntax (use a JSON validator) - File permissions (should be readable) - Wrong mount path (must be `/app/config.json`) - Dangerous variables blocked (PATH, LD_PRELOAD, etc.) ### Custom Database Path Not Working (v2.7.16+) **Symptoms:** - `NODE_DB_PATH` environment variable is set but ignored - Database always created at `/app/data/nodes.db` - Custom path setting has no effect **Root Cause:** Fixed in v2.7.16. Earlier versions had hardcoded paths in docker-entrypoint.sh. **Solutions:** 1. **Update to v2.7.16 or later:** ```bash docker pull ghcr.io/czlonkowski/n8n-mcp:latest ``` 2. **Ensure path ends with .db:** ```bash # Correct NODE_DB_PATH=/app/data/custom/my-nodes.db # Incorrect (will be rejected) NODE_DB_PATH=/app/data/custom/my-nodes ``` 3. **Use path within mounted volume for persistence:** ```yaml services: n8n-mcp: environment: NODE_DB_PATH: /app/data/custom/nodes.db volumes: - n8n-mcp-data:/app/data # Ensure parent directory is mounted ``` ### 502 Bad Gateway Errors **Symptoms:** - `n8n_health_check` returns 502 error - All n8n management API calls fail - n8n web UI is accessible but API is not **Root Cause:** Network connectivity issues between n8n-mcp container and n8n instance. **Solutions:** #### 1. When n8n runs in Docker on same machine Use Docker's special hostnames instead of `localhost`: ```json { "mcpServers": { "n8n-mcp": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "N8N_API_URL=http://host.docker.internal:5678", "-e", "N8N_API_KEY=your-api-key", "ghcr.io/czlonkowski/n8n-mcp:latest" ] } } } ``` **Alternative hostnames to try:** - `host.docker.internal` (Docker Desktop on macOS/Windows) - `172.17.0.1` (Default Docker bridge IP on Linux) - Your machine's actual IP address (e.g., `192.168.1.100`) #### 2. When both containers are in same Docker network ```bash # Create a shared network docker network create n8n-network # Run n8n in the network docker run -d --name n8n --network n8n-network -p 5678:5678 n8nio/n8n # Configure n8n-mcp to use container name ``` ```json { "N8N_API_URL": "http://n8n:5678" } ``` #### 3. For Docker Compose setups ```yaml # docker-compose.yml services: n8n: image: n8nio/n8n container_name: n8n networks: - n8n-net ports: - "5678:5678" n8n-mcp: image: ghcr.io/czlonkowski/n8n-mcp:latest environment: N8N_API_URL: http://n8n:5678 N8N_API_KEY: ${N8N_API_KEY} networks: - n8n-net networks: n8n-net: driver: bridge ``` ### Container Cleanup Issues (Fixed in v2.7.20+) **Symptoms:** - Containers accumulate after Claude Desktop restarts - Containers show as "unhealthy" but don't clean up - `--rm` flag doesn't work as expected **Root Cause:** Fixed in v2.7.20 - containers weren't handling termination signals properly. **Solutions:** 1. **Update to v2.7.20+ and use --init flag (Recommended):** ```json { "command": "docker", "args": [ "run", "-i", "--rm", "--init", "ghcr.io/czlonkowski/n8n-mcp:latest" ] } ``` 2. **Manual cleanup of old containers:** ```bash # Remove all stopped n8n-mcp containers docker ps -a | grep n8n-mcp | grep Exited | awk '{print $1}' | xargs -r docker rm ``` 3. **For versions before 2.7.20:** - Manually clean up containers periodically - Consider using HTTP mode instead ### Webhooks to Local n8n Fail (v2.16.3+) **Symptoms:** - `n8n_trigger_webhook_workflow` fails with "SSRF protection" error - Error message: "SSRF protection: Localhost access is blocked" - Webhooks work from n8n UI but not from n8n-MCP **Root Cause:** Default strict SSRF protection blocks localhost access to prevent attacks. **Solution:** Use moderate security mode for local development ```bash # For Docker run docker run -d \ --name n8n-mcp \ -e MCP_MODE=http \ -e AUTH_TOKEN=your-token \ -e WEBHOOK_SECURITY_MODE=moderate \ -p 3000:3000 \ ghcr.io/czlonkowski/n8n-mcp:latest # For Docker Compose - add to environment: services: n8n-mcp: environment: WEBHOOK_SECURITY_MODE: moderate ``` **Security Modes Explained:** - `strict` (default): Blocks localhost + private IPs + cloud metadata (production) - `moderate`: Allows localhost, blocks private IPs + cloud metadata (local development) - `permissive`: Allows localhost + private IPs, blocks cloud metadata (testing only) **Important:** Always use `strict` mode in production. Cloud metadata is blocked in all modes. ### n8n API Connection Issues **Symptoms:** - API calls fail but n8n web UI works - Authentication errors - API endpoints return 404 **Solutions:** 1. **Verify n8n API is enabled:** - Check n8n settings → REST API is enabled - Ensure API key is valid and not expired 2. **Test API directly:** ```bash # From host machine curl -H "X-N8N-API-KEY: your-key" http://localhost:5678/api/v1/workflows # From inside Docker container docker run --rm curlimages/curl \ -H "X-N8N-API-KEY: your-key" \ http://host.docker.internal:5678/api/v1/workflows ``` 3. **Check n8n environment variables:** ```yaml environment: - N8N_BASIC_AUTH_ACTIVE=true - N8N_BASIC_AUTH_USER=user - N8N_BASIC_AUTH_PASSWORD=password ``` ## Docker Networking ### Understanding Docker Network Modes | Scenario | Use This URL | Why | |----------|--------------|-----| | n8n on host, n8n-mcp in Docker | `http://host.docker.internal:5678` | Docker can't reach host's localhost | | Both in same Docker network | `http://container-name:5678` | Direct container-to-container | | n8n behind reverse proxy | `http://your-domain.com` | Use public URL | | Local development | `http://YOUR_LOCAL_IP:5678` | Use machine's IP address | ### Finding Your Configuration ```bash # Check if n8n is running in Docker docker ps | grep n8n # Find Docker network docker network ls # Get container details docker inspect n8n | grep NetworkMode # Find your local IP # macOS/Linux ifconfig | grep "inet " | grep -v 127.0.0.1 # Windows ipconfig | findstr IPv4 ``` ## Quick Solutions ### Solution 1: Use Host Network (Linux only) ```json { "command": "docker", "args": [ "run", "-i", "--rm", "--network", "host", "-e", "N8N_API_URL=http://localhost:5678", "ghcr.io/czlonkowski/n8n-mcp:latest" ] } ``` ### Solution 2: Use Your Machine's IP ```json { "N8N_API_URL": "http://192.168.1.100:5678" // Replace with your IP } ``` ### Solution 3: HTTP Mode Deployment Deploy n8n-mcp as HTTP server to avoid stdio/Docker issues: ```bash # Start HTTP server docker run -d \ -p 3000:3000 \ -e MCP_MODE=http \ -e AUTH_TOKEN=your-token \ -e N8N_API_URL=http://host.docker.internal:5678 \ -e N8N_API_KEY=your-n8n-key \ ghcr.io/czlonkowski/n8n-mcp:latest # Configure Claude with mcp-remote ``` ## Debugging Steps ### 1. Enable Debug Logging ```json { "env": { "LOG_LEVEL": "debug", "DEBUG_MCP": "true" } } ``` ### 2. Test Connectivity ```bash # Test from n8n-mcp container docker run --rm ghcr.io/czlonkowski/n8n-mcp:latest \ sh -c "apk add curl && curl -v http://host.docker.internal:5678/api/v1/workflows" ``` ### 3. Check Docker Logs ```bash # View n8n-mcp logs docker logs $(docker ps -q -f ancestor=ghcr.io/czlonkowski/n8n-mcp:latest) # View n8n logs docker logs n8n ``` ### 4. Validate Environment ```bash # Check what n8n-mcp sees docker run --rm ghcr.io/czlonkowski/n8n-mcp:latest \ sh -c "env | grep N8N" ``` ### 5. Network Diagnostics ```bash # Check Docker networks docker network inspect bridge # Test DNS resolution docker run --rm busybox nslookup host.docker.internal ``` ## Platform-Specific Notes ### Docker Desktop (macOS/Windows) - `host.docker.internal` works out of the box - Ensure Docker Desktop is running - Check Docker Desktop settings → Resources → Network ### Linux - `host.docker.internal` requires Docker 20.10+ - Alternative: Use `--add-host=host.docker.internal:host-gateway` - Or use the Docker bridge IP: `172.17.0.1` ### Windows with WSL2 - Use `host.docker.internal` or WSL2 IP - Check firewall rules for port 5678 - Ensure n8n binds to `0.0.0.0` not `127.0.0.1` ## Still Having Issues? 1. **Check n8n logs** for API-related errors 2. **Verify firewall/security** isn't blocking connections 3. **Try simpler setup** - Run n8n-mcp on host instead of Docker 4. **Report issue** with debug logs at [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues) ## Useful Commands ```bash # Remove all n8n-mcp containers docker rm -f $(docker ps -aq -f ancestor=ghcr.io/czlonkowski/n8n-mcp:latest) # Test n8n API with curl curl -H "X-N8N-API-KEY: your-key" http://localhost:5678/api/v1/workflows # Run interactive debug session docker run -it --rm \ -e LOG_LEVEL=debug \ -e N8N_API_URL=http://host.docker.internal:5678 \ -e N8N_API_KEY=your-key \ ghcr.io/czlonkowski/n8n-mcp:latest \ sh # Check container networking docker run --rm alpine ping -c 4 host.docker.internal ``` ``` -------------------------------------------------------------------------------- /tests/fixtures/factories/parser-node.factory.ts: -------------------------------------------------------------------------------- ```typescript import { Factory } from 'fishery'; import { faker } from '@faker-js/faker'; // Declarative node definition export interface DeclarativeNodeDefinition { name: string; displayName: string; description: string; version?: number | number[]; group?: string[]; categories?: string[]; routing: { request?: { resource?: { options: Array<{ name: string; value: string }>; }; operation?: { options: Record<string, Array<{ name: string; value: string; action?: string }>>; }; }; }; properties?: any[]; credentials?: any[]; usableAsTool?: boolean; webhooks?: any[]; polling?: boolean; } // Programmatic node definition export interface ProgrammaticNodeDefinition { name: string; displayName: string; description: string; version?: number | number[]; group?: string[]; categories?: string[]; properties: any[]; credentials?: any[]; usableAsTool?: boolean; webhooks?: any[]; polling?: boolean; trigger?: boolean; eventTrigger?: boolean; } // Versioned node class structure export interface VersionedNodeClass { baseDescription?: { name: string; displayName: string; description: string; defaultVersion: number; }; nodeVersions?: Record<number, { description: any }>; } // Property definition export interface PropertyDefinition { displayName: string; name: string; type: string; default?: any; description?: string; options?: Array<{ name: string; value: string; description?: string; action?: string; displayName?: string }> | any[]; required?: boolean; displayOptions?: { show?: Record<string, any[]>; hide?: Record<string, any[]>; }; typeOptions?: any; noDataExpression?: boolean; } // Base property factory export const propertyFactory = Factory.define<PropertyDefinition>(() => ({ displayName: faker.helpers.arrayElement(['Resource', 'Operation', 'Field', 'Option']), name: faker.helpers.slugify(faker.word.noun()).toLowerCase(), type: faker.helpers.arrayElement(['string', 'number', 'boolean', 'options', 'json', 'collection']), default: '', description: faker.lorem.sentence(), required: faker.datatype.boolean(), noDataExpression: faker.datatype.boolean() })); // String property factory export const stringPropertyFactory = propertyFactory.params({ type: 'string', default: faker.lorem.word() }); // Number property factory export const numberPropertyFactory = propertyFactory.params({ type: 'number', default: faker.number.int({ min: 0, max: 100 }) }); // Boolean property factory export const booleanPropertyFactory = propertyFactory.params({ type: 'boolean', default: faker.datatype.boolean() }); // Options property factory export const optionsPropertyFactory = propertyFactory.params({ type: 'options', options: [ { name: 'Option A', value: 'a', description: 'First option' }, { name: 'Option B', value: 'b', description: 'Second option' }, { name: 'Option C', value: 'c', description: 'Third option' } ], default: 'a' }); // Resource property for programmatic nodes export const resourcePropertyFactory = optionsPropertyFactory.params({ displayName: 'Resource', name: 'resource', options: [ { name: 'User', value: 'user' }, { name: 'Post', value: 'post' }, { name: 'Comment', value: 'comment' } ] }); // Operation property for programmatic nodes export const operationPropertyFactory = optionsPropertyFactory.params({ displayName: 'Operation', name: 'operation', displayOptions: { show: { resource: ['user'] } }, options: [ { name: 'Create', value: 'create', action: 'Create a user' } as any, { name: 'Get', value: 'get', action: 'Get a user' } as any, { name: 'Update', value: 'update', action: 'Update a user' } as any, { name: 'Delete', value: 'delete', action: 'Delete a user' } as any ] }); // Collection property factory export const collectionPropertyFactory = propertyFactory.params({ type: 'collection', default: {}, options: [ stringPropertyFactory.build({ name: 'field1', displayName: 'Field 1' }) as any, numberPropertyFactory.build({ name: 'field2', displayName: 'Field 2' }) as any ] }); // Declarative node factory export const declarativeNodeFactory = Factory.define<DeclarativeNodeDefinition>(() => ({ name: faker.helpers.slugify(faker.company.name()).toLowerCase(), displayName: faker.company.name(), description: faker.lorem.sentence(), version: faker.number.int({ min: 1, max: 3 }), group: [faker.helpers.arrayElement(['transform', 'output'])], routing: { request: { resource: { options: [ { name: 'User', value: 'user' }, { name: 'Post', value: 'post' } ] }, operation: { options: { user: [ { name: 'Create', value: 'create', action: 'Create a user' }, { name: 'Get', value: 'get', action: 'Get a user' } ], post: [ { name: 'Create', value: 'create', action: 'Create a post' }, { name: 'List', value: 'list', action: 'List posts' } ] } } } }, properties: [ stringPropertyFactory.build({ name: 'apiKey', displayName: 'API Key' }) ], credentials: [ { name: 'apiCredentials', required: true } ] })); // Programmatic node factory export const programmaticNodeFactory = Factory.define<ProgrammaticNodeDefinition>(() => ({ name: faker.helpers.slugify(faker.company.name()).toLowerCase(), displayName: faker.company.name(), description: faker.lorem.sentence(), version: faker.number.int({ min: 1, max: 3 }), group: [faker.helpers.arrayElement(['transform', 'output'])], properties: [ resourcePropertyFactory.build(), operationPropertyFactory.build(), stringPropertyFactory.build({ name: 'field', displayName: 'Field', displayOptions: { show: { resource: ['user'], operation: ['create', 'update'] } } }) ], credentials: [] })); // Trigger node factory export const triggerNodeFactory = programmaticNodeFactory.params({ group: ['trigger'], trigger: true, properties: [ { displayName: 'Event', name: 'event', type: 'options', default: 'created', options: [ { name: 'Created', value: 'created' }, { name: 'Updated', value: 'updated' }, { name: 'Deleted', value: 'deleted' } ] } ] }); // Webhook node factory export const webhookNodeFactory = programmaticNodeFactory.params({ group: ['trigger'], webhooks: [ { name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' } ], properties: [ { displayName: 'Path', name: 'path', type: 'string', default: 'webhook', required: true } ] }); // AI tool node factory export const aiToolNodeFactory = declarativeNodeFactory.params({ usableAsTool: true, name: 'openai', displayName: 'OpenAI', description: 'Use OpenAI models' }); // Versioned node class factory export const versionedNodeClassFactory = Factory.define<VersionedNodeClass>(() => ({ baseDescription: { name: faker.helpers.slugify(faker.company.name()).toLowerCase(), displayName: faker.company.name(), description: faker.lorem.sentence(), defaultVersion: 2 }, nodeVersions: { 1: { description: { properties: [ stringPropertyFactory.build({ name: 'oldField', displayName: 'Old Field' }) ] } }, 2: { description: { properties: [ stringPropertyFactory.build({ name: 'newField', displayName: 'New Field' }), numberPropertyFactory.build({ name: 'version', displayName: 'Version' }) ] } } } })); // Malformed node factory (for error testing) export const malformedNodeFactory = Factory.define<any>(() => ({ // Missing required 'name' property displayName: faker.company.name(), description: faker.lorem.sentence() })); // Complex nested property factory export const nestedPropertyFactory = Factory.define<PropertyDefinition>(() => ({ displayName: 'Advanced Options', name: 'advancedOptions', type: 'collection', default: {}, options: [ { displayName: 'Headers', name: 'headers', type: 'fixedCollection', typeOptions: { multipleValues: true }, options: [ { name: 'header', displayName: 'Header', values: [ stringPropertyFactory.build({ name: 'name', displayName: 'Name' }), stringPropertyFactory.build({ name: 'value', displayName: 'Value' }) ] } ] } as any, { displayName: 'Query Parameters', name: 'queryParams', type: 'collection', options: [ stringPropertyFactory.build({ name: 'key', displayName: 'Key' }), stringPropertyFactory.build({ name: 'value', displayName: 'Value' }) ] as any[] } as any ] })); // Node class mock factory export const nodeClassFactory = Factory.define<any>(({ params }) => { const description = params.description || programmaticNodeFactory.build(); return class MockNode { description = description; constructor() { // Constructor logic if needed } }; }); // Versioned node type class mock export const versionedNodeTypeClassFactory = Factory.define<any>(({ params }) => { const baseDescription = params.baseDescription || { name: 'versionedNode', displayName: 'Versioned Node', description: 'A versioned node', defaultVersion: 2 }; const nodeVersions = params.nodeVersions || { 1: { description: { properties: [propertyFactory.build()] } }, 2: { description: { properties: [propertyFactory.build(), propertyFactory.build()] } } }; return class VersionedNodeType { baseDescription = baseDescription; nodeVersions = nodeVersions; currentVersion = baseDescription.defaultVersion; constructor() { Object.defineProperty(this.constructor, 'name', { value: 'VersionedNodeType', writable: false, configurable: true }); } }; }); ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/protocol-compliance.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { TestableN8NMCPServer } from './test-helpers'; describe('MCP Protocol Compliance', () => { let mcpServer: TestableN8NMCPServer; let transport: InMemoryTransport; let client: Client; beforeEach(async () => { mcpServer = new TestableN8NMCPServer(); await mcpServer.initialize(); const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); transport = serverTransport; // Connect MCP server to transport await mcpServer.connectToTransport(transport); // Create client client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); await client.connect(clientTransport); }); afterEach(async () => { await client.close(); await mcpServer.close(); }); describe('JSON-RPC 2.0 Compliance', () => { it('should return proper JSON-RPC 2.0 response format', async () => { const response = await client.listTools(); // Response should have tools array expect(response).toHaveProperty('tools'); expect(Array.isArray((response as any).tools)).toBe(true); }); it('should handle request with id correctly', async () => { const response = await client.listTools(); expect(response).toBeDefined(); expect(typeof response).toBe('object'); }); it('should handle batch requests', async () => { // Send multiple requests concurrently const promises = [ client.listTools(), client.listTools(), client.listTools() ]; const responses = await Promise.all(promises); expect(responses).toHaveLength(3); responses.forEach(response => { expect(response).toHaveProperty('tools'); }); }); it('should preserve request order in responses', async () => { const requests = []; const expectedOrder = []; // Create requests with different tools to track order for (let i = 0; i < 5; i++) { expectedOrder.push(i); requests.push( client.callTool({ name: 'get_database_statistics', arguments: {} }) .then(() => i) ); } const results = await Promise.all(requests); expect(results).toEqual(expectedOrder); }); }); describe('Protocol Version Negotiation', () => { it('should negotiate protocol capabilities', async () => { const serverInfo = await client.getServerVersion(); expect(serverInfo).toHaveProperty('name'); expect(serverInfo).toHaveProperty('version'); expect(serverInfo!.name).toBe('n8n-documentation-mcp'); }); it('should expose supported capabilities', async () => { const serverCapabilities = client.getServerCapabilities(); expect(serverCapabilities).toBeDefined(); // Should support tools expect(serverCapabilities).toHaveProperty('tools'); }); }); describe('Message Format Validation', () => { it('should reject messages without method', async () => { // Test by sending raw message through transport const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); const testClient = new Client({ name: 'test', version: '1.0.0' }, {}); await mcpServer.connectToTransport(serverTransport); await testClient.connect(clientTransport); try { // This should fail as MCP SDK validates method await (testClient as any).request({ method: '', params: {} }); expect.fail('Should have thrown an error'); } catch (error) { expect(error).toBeDefined(); } finally { await testClient.close(); } }); it('should handle missing params gracefully', async () => { // Most tools should work without params const response = await client.callTool({ name: 'list_nodes', arguments: {} }); expect(response).toBeDefined(); }); it('should validate params schema', async () => { try { // Invalid nodeType format (missing prefix) const response = await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'httpRequest' // Should be 'nodes-base.httpRequest' } }); // Check if the response indicates an error const text = (response as any).content[0].text; expect(text).toContain('not found'); } catch (error: any) { // If it throws, that's also acceptable expect(error.message).toContain('not found'); } }); }); describe('Content Types', () => { it('should handle text content in tool responses', async () => { const response = await client.callTool({ name: 'get_database_statistics', arguments: {} }); expect((response as any).content).toHaveLength(1); expect((response as any).content[0]).toHaveProperty('type', 'text'); expect((response as any).content[0]).toHaveProperty('text'); expect(typeof (response as any).content[0].text).toBe('string'); }); it('should handle large text responses', async () => { // Get a large node info response const response = await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'nodes-base.httpRequest' } }); expect((response as any).content).toHaveLength(1); expect((response as any).content[0].type).toBe('text'); expect((response as any).content[0].text.length).toBeGreaterThan(1000); }); it('should handle JSON content properly', async () => { const response = await client.callTool({ name: 'list_nodes', arguments: { limit: 5 } }); expect((response as any).content).toHaveLength(1); const content = JSON.parse((response as any).content[0].text); expect(content).toHaveProperty('nodes'); expect(Array.isArray(content.nodes)).toBe(true); }); }); describe('Request/Response Correlation', () => { it('should correlate concurrent requests correctly', async () => { const requests = [ client.callTool({ name: 'get_node_essentials', arguments: { nodeType: 'nodes-base.httpRequest' } }), client.callTool({ name: 'get_node_essentials', arguments: { nodeType: 'nodes-base.webhook' } }), client.callTool({ name: 'get_node_essentials', arguments: { nodeType: 'nodes-base.slack' } }) ]; const responses = await Promise.all(requests); expect((responses[0] as any).content[0].text).toContain('httpRequest'); expect((responses[1] as any).content[0].text).toContain('webhook'); expect((responses[2] as any).content[0].text).toContain('slack'); }); it('should handle interleaved requests', async () => { const results: string[] = []; // Start multiple requests with different delays const p1 = client.callTool({ name: 'get_database_statistics', arguments: {} }) .then(() => { results.push('stats'); return 'stats'; }); const p2 = client.callTool({ name: 'list_nodes', arguments: { limit: 1 } }) .then(() => { results.push('nodes'); return 'nodes'; }); const p3 = client.callTool({ name: 'search_nodes', arguments: { query: 'http' } }) .then(() => { results.push('search'); return 'search'; }); const resolved = await Promise.all([p1, p2, p3]); // All should complete expect(resolved).toHaveLength(3); expect(results).toHaveLength(3); }); }); describe('Protocol Extensions', () => { it('should handle tool-specific extensions', async () => { // Test tool with complex params const response = await client.callTool({ name: 'validate_node_operation', arguments: { nodeType: 'nodes-base.httpRequest', config: { method: 'GET', url: 'https://api.example.com' }, profile: 'runtime' } }); expect((response as any).content).toHaveLength(1); expect((response as any).content[0].type).toBe('text'); }); it('should support optional parameters', async () => { // Call with minimal params const response1 = await client.callTool({ name: 'list_nodes', arguments: {} }); // Call with all params const response2 = await client.callTool({ name: 'list_nodes', arguments: { limit: 10, category: 'trigger', package: 'n8n-nodes-base' } }); expect(response1).toBeDefined(); expect(response2).toBeDefined(); }); }); describe('Transport Layer', () => { it('should handle transport disconnection gracefully', async () => { const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); const testClient = new Client({ name: 'test', version: '1.0.0' }, {}); await mcpServer.connectToTransport(serverTransport); await testClient.connect(clientTransport); // Make a request const response = await testClient.callTool({ name: 'get_database_statistics', arguments: {} }); expect(response).toBeDefined(); // Close client await testClient.close(); // Further requests should fail try { await testClient.callTool({ name: 'get_database_statistics', arguments: {} }); expect.fail('Should have thrown an error'); } catch (error) { expect(error).toBeDefined(); } }); it('should handle multiple sequential connections', async () => { // Close existing connection await client.close(); await mcpServer.close(); // Create new connections for (let i = 0; i < 3; i++) { const engine = new TestableN8NMCPServer(); await engine.initialize(); const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); await engine.connectToTransport(serverTransport); const testClient = new Client({ name: 'test', version: '1.0.0' }, {}); await testClient.connect(clientTransport); const response = await testClient.callTool({ name: 'get_database_statistics', arguments: {} }); expect(response).toBeDefined(); await testClient.close(); await engine.close(); } }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/ai-validation/llm-chain-validation.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Tests: Basic LLM Chain Validation * * Tests Basic LLM Chain validation against real n8n instance. */ import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { createTestContext, TestContext, createTestWorkflowName } from '../n8n-api/utils/test-context'; import { getTestN8nClient } from '../n8n-api/utils/n8n-client'; import { N8nApiClient } from '../../../src/services/n8n-api-client'; import { cleanupOrphanedWorkflows } from '../n8n-api/utils/cleanup-helpers'; import { createMcpContext } from '../n8n-api/utils/mcp-context'; import { InstanceContext } from '../../../src/types/instance-context'; import { handleValidateWorkflow } from '../../../src/mcp/handlers-n8n-manager'; import { getNodeRepository, closeNodeRepository } from '../n8n-api/utils/node-repository'; import { NodeRepository } from '../../../src/database/node-repository'; import { ValidationResponse } from '../n8n-api/types/mcp-responses'; import { createBasicLLMChainNode, createLanguageModelNode, createMemoryNode, createAIConnection, mergeConnections, createAIWorkflow } from './helpers'; import { WorkflowNode } from '../../../src/types/n8n-api'; describe('Integration: Basic LLM Chain Validation', () => { let context: TestContext; let client: N8nApiClient; let mcpContext: InstanceContext; let repository: NodeRepository; beforeEach(async () => { context = createTestContext(); client = getTestN8nClient(); mcpContext = createMcpContext(); repository = await getNodeRepository(); }); afterEach(async () => { await context.cleanup(); }); afterAll(async () => { await closeNodeRepository(); if (!process.env.CI) { await cleanupOrphanedWorkflows(); } }); // ====================================================================== // TEST 1: Missing Language Model // ====================================================================== it('should detect missing language model', async () => { const llmChain = createBasicLLMChainNode({ name: 'Basic LLM Chain', promptType: 'define', text: 'Test prompt' }); const workflow = createAIWorkflow( [llmChain], {}, // No connections { name: createTestWorkflowName('LLM Chain - Missing Model'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MISSING_LANGUAGE_MODEL'); }); // ====================================================================== // TEST 2: Missing Prompt Text (promptType=define) // ====================================================================== it('should detect missing prompt text', async () => { const languageModel = createLanguageModelNode('openai', { name: 'OpenAI Chat Model' }); const llmChain = createBasicLLMChainNode({ name: 'Basic LLM Chain', promptType: 'define', text: '' // Empty prompt text }); const workflow = createAIWorkflow( [languageModel, llmChain], createAIConnection('OpenAI Chat Model', 'Basic LLM Chain', 'ai_languageModel'), { name: createTestWorkflowName('LLM Chain - Missing Prompt'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MISSING_PROMPT_TEXT'); }); // ====================================================================== // TEST 3: Valid Complete LLM Chain // ====================================================================== it('should validate complete LLM Chain', async () => { const languageModel = createLanguageModelNode('openai', { name: 'OpenAI Chat Model' }); const llmChain = createBasicLLMChainNode({ name: 'Basic LLM Chain', promptType: 'define', text: 'You are a helpful assistant. Answer the following: {{ $json.question }}' }); const workflow = createAIWorkflow( [languageModel, llmChain], createAIConnection('OpenAI Chat Model', 'Basic LLM Chain', 'ai_languageModel'), { name: createTestWorkflowName('LLM Chain - Valid'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(true); expect(data.errors).toBeUndefined(); expect(data.summary.errorCount).toBe(0); }); // ====================================================================== // TEST 4: LLM Chain with Memory // ====================================================================== it('should validate LLM Chain with memory', async () => { const languageModel = createLanguageModelNode('anthropic', { name: 'Anthropic Chat Model' }); const memory = createMemoryNode({ name: 'Window Buffer Memory', contextWindowLength: 10 }); const llmChain = createBasicLLMChainNode({ name: 'Basic LLM Chain', promptType: 'auto' }); const workflow = createAIWorkflow( [languageModel, memory, llmChain], mergeConnections( createAIConnection('Anthropic Chat Model', 'Basic LLM Chain', 'ai_languageModel'), createAIConnection('Window Buffer Memory', 'Basic LLM Chain', 'ai_memory') ), { name: createTestWorkflowName('LLM Chain - With Memory'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(true); expect(data.errors).toBeUndefined(); }); // ====================================================================== // TEST 5: LLM Chain with Multiple Language Models (Error) // ====================================================================== it('should detect multiple language models', async () => { const languageModel1 = createLanguageModelNode('openai', { id: 'model-1', name: 'OpenAI Chat Model 1' }); const languageModel2 = createLanguageModelNode('anthropic', { id: 'model-2', name: 'Anthropic Chat Model' }); const llmChain = createBasicLLMChainNode({ name: 'Basic LLM Chain', promptType: 'define', text: 'Test prompt' }); const workflow = createAIWorkflow( [languageModel1, languageModel2, llmChain], mergeConnections( createAIConnection('OpenAI Chat Model 1', 'Basic LLM Chain', 'ai_languageModel'), createAIConnection('Anthropic Chat Model', 'Basic LLM Chain', 'ai_languageModel') // ERROR: multiple models ), { name: createTestWorkflowName('LLM Chain - Multiple Models'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MULTIPLE_LANGUAGE_MODELS'); }); // ====================================================================== // TEST 6: LLM Chain with Tools (Error - not supported) // ====================================================================== it('should detect tools connection (not supported)', async () => { const languageModel = createLanguageModelNode('openai', { name: 'OpenAI Chat Model' }); // Manually create a tool node const toolNode: WorkflowNode = { id: 'tool-1', name: 'Calculator', type: '@n8n/n8n-nodes-langchain.toolCalculator', typeVersion: 1, position: [250, 400], parameters: {} }; const llmChain = createBasicLLMChainNode({ name: 'Basic LLM Chain', promptType: 'define', text: 'Calculate something' }); const workflow = createAIWorkflow( [languageModel, toolNode, llmChain], mergeConnections( createAIConnection('OpenAI Chat Model', 'Basic LLM Chain', 'ai_languageModel'), createAIConnection('Calculator', 'Basic LLM Chain', 'ai_tool') // ERROR: tools not supported ), { name: createTestWorkflowName('LLM Chain - With Tools'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('TOOLS_NOT_SUPPORTED'); const errorMessages = data.errors!.map(e => e.message).join(' '); expect(errorMessages).toMatch(/AI Agent/i); // Should suggest using AI Agent }); }); ``` -------------------------------------------------------------------------------- /src/utils/cache-utils.ts: -------------------------------------------------------------------------------- ```typescript /** * Cache utilities for flexible instance configuration * Provides hash creation, metrics tracking, and cache configuration */ import { createHash } from 'crypto'; import { LRUCache } from 'lru-cache'; import { logger } from './logger'; /** * Cache metrics for monitoring and optimization */ export interface CacheMetrics { hits: number; misses: number; evictions: number; sets: number; deletes: number; clears: number; size: number; maxSize: number; avgHitRate: number; createdAt: Date; lastResetAt: Date; } /** * Cache configuration options */ export interface CacheConfig { max: number; ttlMinutes: number; } /** * Simple memoization cache for hash results * Limited size to prevent memory growth */ const hashMemoCache = new Map<string, string>(); const MAX_MEMO_SIZE = 1000; /** * Metrics tracking for cache operations */ class CacheMetricsTracker { private metrics!: CacheMetrics; private startTime: Date; constructor() { this.startTime = new Date(); this.reset(); } /** * Reset all metrics to initial state */ reset(): void { this.metrics = { hits: 0, misses: 0, evictions: 0, sets: 0, deletes: 0, clears: 0, size: 0, maxSize: 0, avgHitRate: 0, createdAt: this.startTime, lastResetAt: new Date() }; } /** * Record a cache hit */ recordHit(): void { this.metrics.hits++; this.updateHitRate(); } /** * Record a cache miss */ recordMiss(): void { this.metrics.misses++; this.updateHitRate(); } /** * Record a cache eviction */ recordEviction(): void { this.metrics.evictions++; } /** * Record a cache set operation */ recordSet(): void { this.metrics.sets++; } /** * Record a cache delete operation */ recordDelete(): void { this.metrics.deletes++; } /** * Record a cache clear operation */ recordClear(): void { this.metrics.clears++; } /** * Update cache size metrics */ updateSize(current: number, max: number): void { this.metrics.size = current; this.metrics.maxSize = max; } /** * Update average hit rate */ private updateHitRate(): void { const total = this.metrics.hits + this.metrics.misses; if (total > 0) { this.metrics.avgHitRate = this.metrics.hits / total; } } /** * Get current metrics snapshot */ getMetrics(): CacheMetrics { return { ...this.metrics }; } /** * Get formatted metrics for logging */ getFormattedMetrics(): string { const { hits, misses, evictions, avgHitRate, size, maxSize } = this.metrics; return `Cache Metrics: Hits=${hits}, Misses=${misses}, HitRate=${(avgHitRate * 100).toFixed(2)}%, Size=${size}/${maxSize}, Evictions=${evictions}`; } } // Global metrics tracker instance export const cacheMetrics = new CacheMetricsTracker(); /** * Get cache configuration from environment variables or defaults * @returns Cache configuration with max size and TTL */ export function getCacheConfig(): CacheConfig { const max = parseInt(process.env.INSTANCE_CACHE_MAX || '100', 10); const ttlMinutes = parseInt(process.env.INSTANCE_CACHE_TTL_MINUTES || '30', 10); // Validate configuration bounds const validatedMax = Math.max(1, Math.min(10000, max)) || 100; const validatedTtl = Math.max(1, Math.min(1440, ttlMinutes)) || 30; // Max 24 hours if (validatedMax !== max || validatedTtl !== ttlMinutes) { logger.warn('Cache configuration adjusted to valid bounds', { requestedMax: max, requestedTtl: ttlMinutes, actualMax: validatedMax, actualTtl: validatedTtl }); } return { max: validatedMax, ttlMinutes: validatedTtl }; } /** * Create a secure hash for cache key with memoization * @param input - The input string to hash * @returns SHA-256 hash as hex string */ export function createCacheKey(input: string): string { // Check memoization cache first if (hashMemoCache.has(input)) { return hashMemoCache.get(input)!; } // Create hash const hash = createHash('sha256').update(input).digest('hex'); // Add to memoization cache with size limit if (hashMemoCache.size >= MAX_MEMO_SIZE) { // Remove oldest entries (simple FIFO) const firstKey = hashMemoCache.keys().next().value; if (firstKey) { hashMemoCache.delete(firstKey); } } hashMemoCache.set(input, hash); return hash; } /** * Create LRU cache with metrics tracking * @param onDispose - Optional callback for when items are evicted * @returns Configured LRU cache instance */ export function createInstanceCache<T extends {}>( onDispose?: (value: T, key: string) => void ): LRUCache<string, T> { const config = getCacheConfig(); return new LRUCache<string, T>({ max: config.max, ttl: config.ttlMinutes * 60 * 1000, // Convert to milliseconds updateAgeOnGet: true, dispose: (value, key) => { cacheMetrics.recordEviction(); if (onDispose) { onDispose(value, key); } logger.debug('Cache eviction', { cacheKey: key.substring(0, 8) + '...', metrics: cacheMetrics.getFormattedMetrics() }); } }); } /** * Mutex implementation for cache operations * Prevents race conditions during concurrent access */ export class CacheMutex { private locks: Map<string, Promise<void>> = new Map(); private lockTimeouts: Map<string, NodeJS.Timeout> = new Map(); private readonly timeout: number = 5000; // 5 second timeout /** * Acquire a lock for the given key * @param key - The cache key to lock * @returns Promise that resolves when lock is acquired */ async acquire(key: string): Promise<() => void> { while (this.locks.has(key)) { try { await this.locks.get(key); } catch { // Previous lock failed, we can proceed } } let releaseLock: () => void; const lockPromise = new Promise<void>((resolve) => { releaseLock = () => { resolve(); this.locks.delete(key); const timeout = this.lockTimeouts.get(key); if (timeout) { clearTimeout(timeout); this.lockTimeouts.delete(key); } }; }); this.locks.set(key, lockPromise); // Set timeout to prevent stuck locks const timeout = setTimeout(() => { logger.warn('Cache lock timeout, forcefully releasing', { key: key.substring(0, 8) + '...' }); releaseLock!(); }, this.timeout); this.lockTimeouts.set(key, timeout); return releaseLock!; } /** * Check if a key is currently locked * @param key - The cache key to check * @returns True if the key is locked */ isLocked(key: string): boolean { return this.locks.has(key); } /** * Clear all locks (use with caution) */ clearAll(): void { this.lockTimeouts.forEach(timeout => clearTimeout(timeout)); this.locks.clear(); this.lockTimeouts.clear(); } } /** * Retry configuration for API operations */ export interface RetryConfig { maxAttempts: number; baseDelayMs: number; maxDelayMs: number; jitterFactor: number; } /** * Default retry configuration */ export const DEFAULT_RETRY_CONFIG: RetryConfig = { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 10000, jitterFactor: 0.3 }; /** * Calculate exponential backoff delay with jitter * @param attempt - Current attempt number (0-based) * @param config - Retry configuration * @returns Delay in milliseconds */ export function calculateBackoffDelay(attempt: number, config: RetryConfig = DEFAULT_RETRY_CONFIG): number { const exponentialDelay = Math.min( config.baseDelayMs * Math.pow(2, attempt), config.maxDelayMs ); // Add jitter to prevent thundering herd const jitter = exponentialDelay * config.jitterFactor * Math.random(); return Math.floor(exponentialDelay + jitter); } /** * Execute function with retry logic * @param fn - Function to execute * @param config - Retry configuration * @param context - Optional context for logging * @returns Result of the function */ export async function withRetry<T>( fn: () => Promise<T>, config: RetryConfig = DEFAULT_RETRY_CONFIG, context?: string ): Promise<T> { let lastError: Error; for (let attempt = 0; attempt < config.maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error as Error; // Check if error is retryable if (!isRetryableError(error)) { throw error; } if (attempt < config.maxAttempts - 1) { const delay = calculateBackoffDelay(attempt, config); logger.debug('Retrying operation after delay', { context, attempt: attempt + 1, maxAttempts: config.maxAttempts, delayMs: delay, error: lastError.message }); await new Promise(resolve => setTimeout(resolve, delay)); } } } logger.error('All retry attempts exhausted', { context, attempts: config.maxAttempts, lastError: lastError!.message }); throw lastError!; } /** * Check if an error is retryable * @param error - The error to check * @returns True if the error is retryable */ function isRetryableError(error: any): boolean { // Network errors if (error.code === 'ECONNREFUSED' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { return true; } // HTTP status codes that are retryable if (error.response?.status) { const status = error.response.status; return status === 429 || // Too Many Requests status === 503 || // Service Unavailable status === 504 || // Gateway Timeout (status >= 500 && status < 600); // Server errors } // Timeout errors if (error.message && error.message.toLowerCase().includes('timeout')) { return true; } return false; } /** * Format cache statistics for logging or display * @returns Formatted statistics string */ export function getCacheStatistics(): string { const metrics = cacheMetrics.getMetrics(); const runtime = Date.now() - metrics.createdAt.getTime(); const runtimeMinutes = Math.floor(runtime / 60000); return ` Cache Statistics: Runtime: ${runtimeMinutes} minutes Total Operations: ${metrics.hits + metrics.misses} Hit Rate: ${(metrics.avgHitRate * 100).toFixed(2)}% Current Size: ${metrics.size}/${metrics.maxSize} Total Evictions: ${metrics.evictions} Sets: ${metrics.sets}, Deletes: ${metrics.deletes}, Clears: ${metrics.clears} `.trim(); } ``` -------------------------------------------------------------------------------- /tests/setup/test-env.ts: -------------------------------------------------------------------------------- ```typescript /** * Test Environment Configuration Loader * * This module handles loading and validating test environment variables * with type safety and default values. */ import * as dotenv from 'dotenv'; import * as path from 'path'; import { existsSync } from 'fs'; // Load test environment variables export function loadTestEnvironment(): void { // CI Debug logging const isCI = process.env.CI === 'true'; // First, load the main .env file (for integration tests that need real credentials) const mainEnvPath = path.resolve(process.cwd(), '.env'); if (existsSync(mainEnvPath)) { dotenv.config({ path: mainEnvPath }); if (isCI) { console.log('[CI-DEBUG] Loaded .env file from:', mainEnvPath); } } // Load base test environment const testEnvPath = path.resolve(process.cwd(), '.env.test'); if (isCI) { console.log('[CI-DEBUG] Looking for .env.test at:', testEnvPath); console.log('[CI-DEBUG] File exists?', existsSync(testEnvPath)); } if (existsSync(testEnvPath)) { // Don't override values from .env const result = dotenv.config({ path: testEnvPath, override: false }); if (isCI && result.error) { console.error('[CI-DEBUG] Failed to load .env.test:', result.error); } else if (isCI && result.parsed) { console.log('[CI-DEBUG] Successfully loaded', Object.keys(result.parsed).length, 'env vars from .env.test'); } } else if (isCI) { console.warn('[CI-DEBUG] .env.test file not found, will use defaults only'); } // Load local test overrides (for sensitive values) const localEnvPath = path.resolve(process.cwd(), '.env.test.local'); if (existsSync(localEnvPath)) { dotenv.config({ path: localEnvPath, override: true }); } // Set test-specific defaults (only if not already set) setTestDefaults(); // Validate required environment variables validateTestEnvironment(); } /** * Set default values for test environment variables */ function setTestDefaults(): void { // Ensure we're in test mode process.env.NODE_ENV = 'test'; process.env.TEST_ENVIRONMENT = 'true'; // Set defaults if not already set const defaults: Record<string, string> = { // Database NODE_DB_PATH: ':memory:', REBUILD_ON_START: 'false', // API N8N_API_URL: 'http://localhost:3001/mock-api', N8N_API_KEY: 'test-api-key-12345', // Server PORT: '3001', HOST: '127.0.0.1', // Logging LOG_LEVEL: 'error', DEBUG: 'false', TEST_LOG_VERBOSE: 'false', // Timeouts TEST_TIMEOUT_UNIT: '5000', TEST_TIMEOUT_INTEGRATION: '15000', TEST_TIMEOUT_E2E: '30000', TEST_TIMEOUT_GLOBAL: '30000', // Reduced from 60s to 30s to catch hangs faster // Test execution TEST_RETRY_ATTEMPTS: '2', TEST_RETRY_DELAY: '1000', TEST_PARALLEL: 'true', TEST_MAX_WORKERS: '4', // Features FEATURE_MOCK_EXTERNAL_APIS: 'true', FEATURE_USE_TEST_CONTAINERS: 'false', MSW_ENABLED: 'true', MSW_API_DELAY: '0', // Paths TEST_FIXTURES_PATH: './tests/fixtures', TEST_DATA_PATH: './tests/data', TEST_SNAPSHOTS_PATH: './tests/__snapshots__', // Performance PERF_THRESHOLD_API_RESPONSE: '100', PERF_THRESHOLD_DB_QUERY: '50', PERF_THRESHOLD_NODE_PARSE: '200', // Caching CACHE_TTL: '0', CACHE_ENABLED: 'false', // Rate limiting RATE_LIMIT_MAX: '0', RATE_LIMIT_WINDOW: '0', // Error handling ERROR_SHOW_STACK: 'true', ERROR_SHOW_DETAILS: 'true', // Cleanup TEST_CLEANUP_ENABLED: 'true', TEST_CLEANUP_ON_FAILURE: 'false', // Database seeding TEST_SEED_DATABASE: 'true', TEST_SEED_TEMPLATES: 'true', // Network NETWORK_TIMEOUT: '5000', NETWORK_RETRY_COUNT: '0', // Memory TEST_MEMORY_LIMIT: '512', // Coverage COVERAGE_DIR: './coverage', COVERAGE_REPORTER: 'lcov,html,text-summary' }; for (const [key, value] of Object.entries(defaults)) { if (!process.env[key]) { process.env[key] = value; } } } /** * Validate that required environment variables are set */ function validateTestEnvironment(): void { const required = [ 'NODE_ENV', 'NODE_DB_PATH', 'N8N_API_URL', 'N8N_API_KEY' ]; const missing = required.filter(key => !process.env[key]); if (missing.length > 0) { throw new Error( `Missing required test environment variables: ${missing.join(', ')}\n` + 'Please ensure .env.test is properly configured.' ); } // Validate NODE_ENV is set to test if (process.env.NODE_ENV !== 'test') { throw new Error( 'NODE_ENV must be set to "test" when running tests.\n' + 'This prevents accidental execution against production systems.' ); } } /** * Get typed test environment configuration */ export function getTestConfig() { // Ensure defaults are set before accessing if (!process.env.N8N_API_URL) { setTestDefaults(); } return { // Environment nodeEnv: process.env.NODE_ENV || 'test', isTest: process.env.TEST_ENVIRONMENT === 'true', // Database database: { path: process.env.NODE_DB_PATH || ':memory:', rebuildOnStart: process.env.REBUILD_ON_START === 'true', seedData: process.env.TEST_SEED_DATABASE === 'true', seedTemplates: process.env.TEST_SEED_TEMPLATES === 'true' }, // API api: { url: process.env.N8N_API_URL || 'http://localhost:3001/mock-api', key: process.env.N8N_API_KEY || 'test-api-key-12345', webhookBaseUrl: process.env.N8N_WEBHOOK_BASE_URL, webhookTestUrl: process.env.N8N_WEBHOOK_TEST_URL }, // Server server: { port: parseInt(process.env.PORT || '3001', 10), host: process.env.HOST || '127.0.0.1', corsOrigin: process.env.CORS_ORIGIN?.split(',') || [] }, // Authentication auth: { token: process.env.AUTH_TOKEN, mcpToken: process.env.MCP_AUTH_TOKEN }, // Logging logging: { level: process.env.LOG_LEVEL || 'error', debug: process.env.DEBUG === 'true', verbose: process.env.TEST_LOG_VERBOSE === 'true', showStack: process.env.ERROR_SHOW_STACK === 'true', showDetails: process.env.ERROR_SHOW_DETAILS === 'true' }, // Test execution execution: { timeouts: { unit: parseInt(process.env.TEST_TIMEOUT_UNIT || '5000', 10), integration: parseInt(process.env.TEST_TIMEOUT_INTEGRATION || '15000', 10), e2e: parseInt(process.env.TEST_TIMEOUT_E2E || '30000', 10), global: parseInt(process.env.TEST_TIMEOUT_GLOBAL || '60000', 10) }, retry: { attempts: parseInt(process.env.TEST_RETRY_ATTEMPTS || '2', 10), delay: parseInt(process.env.TEST_RETRY_DELAY || '1000', 10) }, parallel: process.env.TEST_PARALLEL === 'true', maxWorkers: parseInt(process.env.TEST_MAX_WORKERS || '4', 10) }, // Features features: { coverage: process.env.FEATURE_TEST_COVERAGE === 'true', screenshots: process.env.FEATURE_TEST_SCREENSHOTS === 'true', videos: process.env.FEATURE_TEST_VIDEOS === 'true', trace: process.env.FEATURE_TEST_TRACE === 'true', mockExternalApis: process.env.FEATURE_MOCK_EXTERNAL_APIS === 'true', useTestContainers: process.env.FEATURE_USE_TEST_CONTAINERS === 'true' }, // Mocking mocking: { msw: { enabled: process.env.MSW_ENABLED === 'true', apiDelay: parseInt(process.env.MSW_API_DELAY || '0', 10) }, redis: { enabled: process.env.REDIS_MOCK_ENABLED === 'true', port: parseInt(process.env.REDIS_MOCK_PORT || '6380', 10) }, elasticsearch: { enabled: process.env.ELASTICSEARCH_MOCK_ENABLED === 'true', port: parseInt(process.env.ELASTICSEARCH_MOCK_PORT || '9201', 10) } }, // Paths paths: { fixtures: process.env.TEST_FIXTURES_PATH || './tests/fixtures', data: process.env.TEST_DATA_PATH || './tests/data', snapshots: process.env.TEST_SNAPSHOTS_PATH || './tests/__snapshots__' }, // Performance performance: { thresholds: { apiResponse: parseInt(process.env.PERF_THRESHOLD_API_RESPONSE || '100', 10), dbQuery: parseInt(process.env.PERF_THRESHOLD_DB_QUERY || '50', 10), nodeParse: parseInt(process.env.PERF_THRESHOLD_NODE_PARSE || '200', 10) } }, // Rate limiting rateLimiting: { max: parseInt(process.env.RATE_LIMIT_MAX || '0', 10), window: parseInt(process.env.RATE_LIMIT_WINDOW || '0', 10) }, // Caching cache: { enabled: process.env.CACHE_ENABLED === 'true', ttl: parseInt(process.env.CACHE_TTL || '0', 10) }, // Cleanup cleanup: { enabled: process.env.TEST_CLEANUP_ENABLED === 'true', onFailure: process.env.TEST_CLEANUP_ON_FAILURE === 'true' }, // Network network: { timeout: parseInt(process.env.NETWORK_TIMEOUT || '5000', 10), retryCount: parseInt(process.env.NETWORK_RETRY_COUNT || '0', 10) }, // Memory memory: { limit: parseInt(process.env.TEST_MEMORY_LIMIT || '512', 10) }, // Coverage coverage: { dir: process.env.COVERAGE_DIR || './coverage', reporters: (process.env.COVERAGE_REPORTER || 'lcov,html,text-summary').split(',') } }; } // Export type for the test configuration export type TestConfig = ReturnType<typeof getTestConfig>; /** * Helper to check if we're in test mode */ export function isTestMode(): boolean { return process.env.NODE_ENV === 'test' || process.env.TEST_ENVIRONMENT === 'true'; } /** * Helper to get timeout for specific test type */ export function getTestTimeout(type: 'unit' | 'integration' | 'e2e' | 'global' = 'unit'): number { const config = getTestConfig(); return config.execution.timeouts[type]; } /** * Helper to check if a feature is enabled */ export function isFeatureEnabled(feature: keyof TestConfig['features']): boolean { const config = getTestConfig(); return config.features[feature]; } /** * Reset environment to defaults (useful for test isolation) */ export function resetTestEnvironment(): void { // Clear all test-specific environment variables const testKeys = Object.keys(process.env).filter(key => key.startsWith('TEST_') || key.startsWith('FEATURE_') || key.startsWith('MSW_') || key.startsWith('PERF_') ); testKeys.forEach(key => { delete process.env[key]; }); // Reload defaults loadTestEnvironment(); } ``` -------------------------------------------------------------------------------- /docs/RAILWAY_DEPLOYMENT.md: -------------------------------------------------------------------------------- ```markdown # Railway Deployment Guide for n8n-MCP Deploy n8n-MCP to Railway's cloud platform with zero configuration and connect it to Claude Desktop from anywhere. ## 🚀 Quick Deploy Deploy n8n-MCP with one click: [](https://railway.com/deploy/VY6UOG?referralCode=n8n-mcp) ## 📋 Overview Railway deployment provides: - ☁️ **Instant cloud hosting** - No server setup required - 🔒 **Secure by default** - HTTPS included, auth token warnings - 🌐 **Global access** - Connect from any Claude Desktop - ⚡ **Auto-scaling** - Railway handles the infrastructure - 📊 **Built-in monitoring** - Logs and metrics included ## 🎯 Step-by-Step Deployment ### 1. Deploy to Railway 1. **Click the Deploy button** above 2. **Sign in to Railway** (or create account) 3. **Configure your deployment**: - Project name (optional) - Environment (leave as "production") - Region (choose closest to you) 4. **Click "Deploy"** and wait ~2-3 minutes ### 2. Configure Security **IMPORTANT**: The deployment includes a default AUTH_TOKEN for instant functionality, but you MUST change it:  1. **Go to your Railway dashboard** 2. **Click on your n8n-mcp service** 3. **Navigate to "Variables" tab** 4. **Find `AUTH_TOKEN`** 5. **Replace with secure token**: ```bash # Generate secure token locally: openssl rand -base64 32 ``` 6. **Railway will automatically redeploy** with the new token > ⚠️ **Security Warning**: The server displays warnings every 5 minutes until you change the default token! ### 3. Get Your Service URL  1. In Railway dashboard, click on your service 2. Go to **"Settings"** tab 3. Under **"Domains"**, you'll see your URL: ``` https://your-app-name.up.railway.app ``` 4. Copy this URL for Claude Desktop configuration and add /mcp at the end ### 4. Connect Claude Desktop Add to your Claude Desktop configuration: ```json { "mcpServers": { "n8n-railway": { "command": "npx", "args": [ "-y", "mcp-remote", "https://your-app-name.up.railway.app/mcp", "--header", "Authorization: Bearer YOUR_SECURE_TOKEN_HERE" ] } } } ``` **Configuration file locations:** - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - **Linux**: `~/.config/Claude/claude_desktop_config.json` **Restart Claude Desktop** after saving the configuration. ## 🔧 Environment Variables ### Default Variables (Pre-configured) These are automatically set by the Railway template: | Variable | Default Value | Description | |----------|--------------|-------------| | `AUTH_TOKEN` | `REPLACE_THIS...` | **⚠️ CHANGE IMMEDIATELY** | | `MCP_MODE` | `http` | Required for cloud deployment | | `USE_FIXED_HTTP` | `true` | Stable HTTP implementation | | `NODE_ENV` | `production` | Production optimizations | | `LOG_LEVEL` | `info` | Balanced logging | | `TRUST_PROXY` | `1` | Railway runs behind proxy | | `CORS_ORIGIN` | `*` | Allow any origin | | `HOST` | `0.0.0.0` | Listen on all interfaces | | `PORT` | (Railway provides) | Don't set manually | | `AUTH_RATE_LIMIT_WINDOW` | `900000` (15 min) | Rate limit window (v2.16.3+) | | `AUTH_RATE_LIMIT_MAX` | `20` | Max auth attempts (v2.16.3+) | | `WEBHOOK_SECURITY_MODE` | `strict` | SSRF protection mode (v2.16.3+) | ### Optional Variables | Variable | Default Value | Description | |----------|--------------|-------------| | `N8N_MODE` | `false` | Enable n8n integration mode for MCP Client Tool | | `N8N_API_URL` | - | URL of your n8n instance (for workflow management) | | `N8N_API_KEY` | - | API key from n8n Settings → API | ### Optional: n8n Integration #### For n8n MCP Client Tool Integration To use n8n-MCP with n8n's MCP Client Tool node: 1. **Go to Railway dashboard** → Your service → **Variables** 2. **Add this variable**: - `N8N_MODE`: Set to `true` to enable n8n integration mode 3. **Save changes** - Railway will redeploy automatically #### For n8n API Integration (Workflow Management) To enable workflow management features: 1. **Go to Railway dashboard** → Your service → **Variables** 2. **Add these variables**: - `N8N_API_URL`: Your n8n instance URL (e.g., `https://n8n.example.com`) - `N8N_API_KEY`: API key from n8n Settings → API 3. **Save changes** - Railway will redeploy automatically ## 🏗️ Architecture Details ### How It Works ``` Claude Desktop → mcp-remote → Railway (HTTPS) → n8n-MCP Server ``` 1. **Claude Desktop** uses `mcp-remote` as a bridge 2. **mcp-remote** converts stdio to HTTP requests 3. **Railway** provides HTTPS endpoint and infrastructure 4. **n8n-MCP** runs in HTTP mode on Railway ### Single-Instance Design **Important**: The n8n-MCP HTTP server is designed for single n8n instance deployment: - n8n API credentials are configured server-side via environment variables - All clients connecting to the server share the same n8n instance - For multi-tenant usage, deploy separate Railway instances ### Security Model - **Bearer Token Authentication**: All requests require the AUTH_TOKEN - **HTTPS by Default**: Railway provides SSL certificates - **Environment Isolation**: Each deployment is isolated - **No State Storage**: Server is stateless (database is read-only) ## 🚨 Troubleshooting ### Connection Issues **"Invalid URL" error in Claude Desktop:** - Ensure you're using the exact configuration format shown above - Don't add "connect" or other arguments before the URL - The URL should end with `/mcp` **"Unauthorized" error:** - Check that your AUTH_TOKEN matches exactly (no extra spaces) - Ensure the Authorization header format is correct: `Authorization: Bearer TOKEN` **"Cannot connect to server":** - Verify your Railway deployment is running (check Railway dashboard) - Ensure the URL is correct and includes `https://` - Check Railway logs for any errors **Windows: "The filename, directory name, or volume label syntax is incorrect" or npx command not found:** This is a common Windows issue with spaces in Node.js installation paths. The error occurs because Claude Desktop can't properly execute npx. **Solution 1: Use node directly (Recommended)** ```json { "mcpServers": { "n8n-railway": { "command": "node", "args": [ "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js", "-y", "mcp-remote", "https://your-app-name.up.railway.app/mcp", "--header", "Authorization: Bearer YOUR_SECURE_TOKEN_HERE" ] } } } ``` **Solution 2: Use cmd wrapper** ```json { "mcpServers": { "n8n-railway": { "command": "cmd", "args": [ "/C", "\"C:\\Program Files\\nodejs\\npx\" -y mcp-remote https://your-app-name.up.railway.app/mcp --header \"Authorization: Bearer YOUR_SECURE_TOKEN_HERE\"" ] } } } ``` To find your exact npx path, open Command Prompt and run: `where npx` ### Railway-Specific Issues **Build failures:** - Railway uses AMD64 architecture - the template is configured for this - Check build logs in Railway dashboard for specific errors **Environment variable issues:** - Variables are case-sensitive - Don't include quotes in the Railway dashboard (only in JSON config) - Railway automatically restarts when you change variables **Domain not working:** - It may take 1-2 minutes for the domain to become active - Check the "Deployments" tab to ensure the latest deployment succeeded ## 📊 Monitoring & Logs ### View Logs 1. Go to Railway dashboard 2. Click on your n8n-mcp service 3. Click on **"Logs"** tab 4. You'll see real-time logs including: - Server startup messages - Authentication attempts - API requests (without sensitive data) - Any errors or warnings ### Monitor Usage Railway provides metrics for: - **Memory usage** (typically ~100-200MB) - **CPU usage** (minimal when idle) - **Network traffic** - **Response times** ## 💰 Pricing & Limits ### Railway Free Tier - **$5 free credit** monthly - **500 hours** of runtime - **Sufficient for personal use** of n8n-MCP ### Estimated Costs - **n8n-MCP typically uses**: ~0.1 GB RAM - **Monthly cost**: ~$2-3 for 24/7 operation - **Well within free tier** for most users ## 🔄 Updates & Maintenance ### Manual Updates Since the Railway template uses a specific Docker image tag, updates are manual: 1. **Check for updates** on [GitHub](https://github.com/czlonkowski/n8n-mcp) 2. **Update image tag** in Railway: - Go to Settings → Deploy → Docker Image - Change tag from current to new version - Click "Redeploy" ### Automatic Updates (Not Recommended) You could use the `latest` tag, but this may cause unexpected breaking changes. ## 🔒 Security Features (v2.16.3+) Railway deployments include enhanced security features: ### Rate Limiting - **Automatic brute force protection** - 20 attempts per 15 minutes per IP - **Configurable limits** via `AUTH_RATE_LIMIT_WINDOW` and `AUTH_RATE_LIMIT_MAX` - **Standard rate limit headers** for client awareness ### SSRF Protection - **Default strict mode** blocks localhost, private IPs, and cloud metadata - **Cloud metadata always blocked** (169.254.169.254, metadata.google.internal, etc.) - **Use `moderate` mode only if** connecting to local n8n instance **Security Configuration:** ```bash # In Railway Variables tab: WEBHOOK_SECURITY_MODE=strict # Production (recommended) # or WEBHOOK_SECURITY_MODE=moderate # If using local n8n with port forwarding # Rate limiting (defaults are good for most use cases) AUTH_RATE_LIMIT_WINDOW=900000 # 15 minutes AUTH_RATE_LIMIT_MAX=20 # 20 attempts per IP ``` ## 📝 Best Practices 1. **Always change the default AUTH_TOKEN immediately** 2. **Use strong, unique tokens** (32+ characters) 3. **Monitor logs** for unauthorized access attempts 4. **Keep credentials secure** - never commit them to git 5. **Use environment variables** for all sensitive data 6. **Regular updates** - check for new versions monthly ## 🆘 Getting Help - **Railway Documentation**: [docs.railway.app](https://docs.railway.app) - **n8n-MCP Issues**: [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues) - **Railway Community**: [Discord](https://discord.gg/railway) ## 🎉 Success! Once connected, you can use all n8n-MCP features from Claude Desktop: - Search and explore 500+ n8n nodes - Get node configurations and examples - Validate workflows before deployment - Manage n8n workflows (if API configured) The cloud deployment means you can access your n8n knowledge base from any computer with Claude Desktop installed! ``` -------------------------------------------------------------------------------- /docs/MCP_QUICK_START_GUIDE.md: -------------------------------------------------------------------------------- ```markdown # MCP Implementation Quick Start Guide ## Immediate Actions (Day 1) ### 1. Create Essential Properties Configuration Create `src/data/essential-properties.json`: ```json { "nodes-base.httpRequest": { "required": ["url"], "common": ["method", "authentication", "sendBody", "contentType", "sendHeaders"], "examples": { "minimal": { "url": "https://api.example.com/data" }, "getWithAuth": { "method": "GET", "url": "https://api.example.com/protected", "authentication": "genericCredentialType", "genericAuthType": "headerAuth" }, "postJson": { "method": "POST", "url": "https://api.example.com/create", "sendBody": true, "contentType": "json", "jsonBody": "{ \"name\": \"example\" }" } } }, "nodes-base.webhook": { "required": [], "common": ["path", "method", "responseMode", "responseData"], "examples": { "minimal": { "path": "webhook", "method": "POST" } } } } ``` ### 2. Implement get_node_essentials Tool Add to `src/mcp/server.ts`: ```typescript // Add to tool implementations case "get_node_essentials": { const { nodeType } = request.params.arguments as { nodeType: string }; // Load essential properties config const essentialsConfig = require('../data/essential-properties.json'); const nodeConfig = essentialsConfig[nodeType]; if (!nodeConfig) { // Fallback: extract from existing data const node = await service.getNodeByType(nodeType); if (!node) { return { error: `Node type ${nodeType} not found` }; } // Parse properties to find required ones const properties = JSON.parse(node.properties_schema || '[]'); const required = properties.filter((p: any) => p.required); const common = properties.slice(0, 5); // Top 5 as fallback return { nodeType, displayName: node.display_name, description: node.description, requiredProperties: required.map(simplifyProperty), commonProperties: common.map(simplifyProperty), examples: { minimal: {}, common: {} } }; } // Use configured essentials const node = await service.getNodeByType(nodeType); const properties = JSON.parse(node.properties_schema || '[]'); const requiredProps = nodeConfig.required.map((name: string) => { const prop = findPropertyByName(properties, name); return prop ? simplifyProperty(prop) : null; }).filter(Boolean); const commonProps = nodeConfig.common.map((name: string) => { const prop = findPropertyByName(properties, name); return prop ? simplifyProperty(prop) : null; }).filter(Boolean); return { nodeType, displayName: node.display_name, description: node.description, requiredProperties: requiredProps, commonProperties: commonProps, examples: nodeConfig.examples || {} }; } // Helper functions function simplifyProperty(prop: any) { return { name: prop.name, type: prop.type, description: prop.description || prop.displayName || '', default: prop.default, options: prop.options?.map((opt: any) => typeof opt === 'string' ? opt : opt.value ), placeholder: prop.placeholder }; } function findPropertyByName(properties: any[], name: string): any { for (const prop of properties) { if (prop.name === name) return prop; // Check in nested collections if (prop.type === 'collection' && prop.options) { const found = findPropertyByName(prop.options, name); if (found) return found; } } return null; } ``` ### 3. Add Tool Definition Add to tool definitions: ```typescript { name: "get_node_essentials", description: "Get only essential and commonly-used properties for a node - perfect for quick configuration", inputSchema: { type: "object", properties: { nodeType: { type: "string", description: "The node type (e.g., 'nodes-base.httpRequest')" } }, required: ["nodeType"] } } ``` ### 4. Create Property Parser Service Create `src/services/property-parser.ts`: ```typescript export class PropertyParser { /** * Parse nested properties and flatten to searchable format */ static parseProperties(properties: any[], path = ''): ParsedProperty[] { const results: ParsedProperty[] = []; for (const prop of properties) { const currentPath = path ? `${path}.${prop.name}` : prop.name; // Add current property results.push({ name: prop.name, path: currentPath, type: prop.type, description: prop.description || prop.displayName || '', required: prop.required || false, displayConditions: prop.displayOptions, default: prop.default, options: prop.options?.filter((opt: any) => typeof opt === 'string' || opt.value) }); // Recursively parse nested properties if (prop.type === 'collection' && prop.options) { results.push(...this.parseProperties(prop.options, currentPath)); } else if (prop.type === 'fixedCollection' && prop.options) { for (const option of prop.options) { if (option.values) { results.push(...this.parseProperties(option.values, `${currentPath}.${option.name}`)); } } } } return results; } /** * Find properties matching a search query */ static searchProperties(properties: ParsedProperty[], query: string): ParsedProperty[] { const lowerQuery = query.toLowerCase(); return properties.filter(prop => prop.name.toLowerCase().includes(lowerQuery) || prop.description.toLowerCase().includes(lowerQuery) || prop.path.toLowerCase().includes(lowerQuery) ); } /** * Categorize properties */ static categorizeProperties(properties: ParsedProperty[]): CategorizedProperties { const categories: CategorizedProperties = { authentication: [], request: [], response: [], advanced: [], other: [] }; for (const prop of properties) { if (prop.name.includes('auth') || prop.name.includes('credential')) { categories.authentication.push(prop); } else if (prop.name.includes('body') || prop.name.includes('header') || prop.name.includes('query') || prop.name.includes('url')) { categories.request.push(prop); } else if (prop.name.includes('response') || prop.name.includes('output')) { categories.response.push(prop); } else if (prop.path.includes('options.')) { categories.advanced.push(prop); } else { categories.other.push(prop); } } return categories; } } interface ParsedProperty { name: string; path: string; type: string; description: string; required: boolean; displayConditions?: any; default?: any; options?: any[]; } interface CategorizedProperties { authentication: ParsedProperty[]; request: ParsedProperty[]; response: ParsedProperty[]; advanced: ParsedProperty[]; other: ParsedProperty[]; } ``` ### 5. Quick Test Script Create `scripts/test-essentials.ts`: ```typescript import { MCPClient } from '../src/mcp/client'; async function testEssentials() { const client = new MCPClient(); console.log('Testing get_node_essentials...\n'); // Test HTTP Request node const httpEssentials = await client.call('get_node_essentials', { nodeType: 'nodes-base.httpRequest' }); console.log('HTTP Request Essentials:'); console.log(`- Required: ${httpEssentials.requiredProperties.map(p => p.name).join(', ')}`); console.log(`- Common: ${httpEssentials.commonProperties.map(p => p.name).join(', ')}`); console.log(`- Total properties: ${httpEssentials.requiredProperties.length + httpEssentials.commonProperties.length}`); // Compare with full response const fullInfo = await client.call('get_node_info', { nodeType: 'nodes-base.httpRequest' }); const fullSize = JSON.stringify(fullInfo).length; const essentialSize = JSON.stringify(httpEssentials).length; console.log(`\nSize comparison:`); console.log(`- Full response: ${(fullSize / 1024).toFixed(1)}KB`); console.log(`- Essential response: ${(essentialSize / 1024).toFixed(1)}KB`); console.log(`- Reduction: ${((1 - essentialSize / fullSize) * 100).toFixed(1)}%`); } testEssentials().catch(console.error); ``` ## Day 2-3: Implement search_node_properties ```typescript case "search_node_properties": { const { nodeType, query } = request.params.arguments as { nodeType: string; query: string; }; const node = await service.getNodeByType(nodeType); if (!node) { return { error: `Node type ${nodeType} not found` }; } const properties = JSON.parse(node.properties_schema || '[]'); const parsed = PropertyParser.parseProperties(properties); const matches = PropertyParser.searchProperties(parsed, query); return { query, matches: matches.map(prop => ({ name: prop.name, type: prop.type, path: prop.path, description: prop.description, visibleWhen: prop.displayConditions?.show })), totalMatches: matches.length }; } ``` ## Day 4-5: Implement get_node_for_task Create `src/data/task-templates.json`: ```json { "post_json_request": { "description": "Make a POST request with JSON data", "nodeType": "nodes-base.httpRequest", "configuration": { "method": "POST", "url": "", "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "" }, "userMustProvide": [ { "property": "url", "description": "API endpoint URL" }, { "property": "jsonBody", "description": "JSON data to send" } ], "optionalEnhancements": [ { "property": "authentication", "description": "Add authentication if required" }, { "property": "sendHeaders", "description": "Add custom headers" } ] } } ``` ## Testing Checklist - [ ] Test get_node_essentials with HTTP Request node - [ ] Verify size reduction is >90% - [ ] Test with Webhook, Agent, and Code nodes - [ ] Validate examples work correctly - [ ] Test property search functionality - [ ] Verify task templates are valid - [ ] Check backward compatibility - [ ] Measure response times (<100ms) ## Success Indicators 1. **Immediate (Day 1)**: - get_node_essentials returns <5KB for HTTP Request - Response includes working examples - No errors with top 10 nodes 2. **Week 1**: - 90% reduction in response size - Property search working - 5+ task templates created - Positive AI agent feedback 3. **Month 1**: - All tools implemented - 50+ nodes optimized - Configuration time <1 minute - Error rate <10% ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/executions/trigger-webhook.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Tests: handleTriggerWebhookWorkflow * * Tests webhook triggering against a real n8n instance. * Covers all HTTP methods, request data, headers, and error handling. */ import { describe, it, expect, beforeEach } from 'vitest'; import { createMcpContext } from '../utils/mcp-context'; import { InstanceContext } from '../../../../src/types/instance-context'; import { handleTriggerWebhookWorkflow } from '../../../../src/mcp/handlers-n8n-manager'; import { getN8nCredentials } from '../utils/credentials'; describe('Integration: handleTriggerWebhookWorkflow', () => { let mcpContext: InstanceContext; let webhookUrls: { get: string; post: string; put: string; delete: string; }; beforeEach(() => { mcpContext = createMcpContext(); const creds = getN8nCredentials(); webhookUrls = creds.webhookUrls; }); // ====================================================================== // GET Method Tests // ====================================================================== describe('GET Method', () => { it('should trigger GET webhook without data', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.get, httpMethod: 'GET' }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); expect(response.message).toContain('Webhook triggered successfully'); }); it('should trigger GET webhook with query parameters', async () => { // GET method uses query parameters in URL const urlWithParams = `${webhookUrls.get}?testParam=value&number=42`; const response = await handleTriggerWebhookWorkflow( { webhookUrl: urlWithParams, httpMethod: 'GET' }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger GET webhook with custom headers', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.get, httpMethod: 'GET', headers: { 'X-Custom-Header': 'test-value', 'X-Request-Id': '12345' } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger GET webhook and wait for response', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.get, httpMethod: 'GET', waitForResponse: true }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); // Response should contain workflow execution data }); }); // ====================================================================== // POST Method Tests // ====================================================================== describe('POST Method', () => { it('should trigger POST webhook with JSON data', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.post, httpMethod: 'POST', data: { message: 'Test webhook trigger', timestamp: Date.now(), nested: { value: 'nested data' } } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger POST webhook without data', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.post, httpMethod: 'POST' }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger POST webhook with custom headers', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.post, httpMethod: 'POST', data: { test: 'data' }, headers: { 'Content-Type': 'application/json', 'X-Api-Key': 'test-key' } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger POST webhook without waiting for response', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.post, httpMethod: 'POST', data: { async: true }, waitForResponse: false }, mcpContext ); expect(response.success).toBe(true); // With waitForResponse: false, may return immediately }); }); // ====================================================================== // PUT Method Tests // ====================================================================== describe('PUT Method', () => { it('should trigger PUT webhook with update data', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.put, httpMethod: 'PUT', data: { id: '123', updatedField: 'new value', timestamp: Date.now() } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger PUT webhook with custom headers', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.put, httpMethod: 'PUT', data: { update: true }, headers: { 'X-Update-Operation': 'modify', 'If-Match': 'etag-value' } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger PUT webhook without data', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.put, httpMethod: 'PUT' }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); }); // ====================================================================== // DELETE Method Tests // ====================================================================== describe('DELETE Method', () => { it('should trigger DELETE webhook with query parameters', async () => { const urlWithParams = `${webhookUrls.delete}?id=123&reason=test`; const response = await handleTriggerWebhookWorkflow( { webhookUrl: urlWithParams, httpMethod: 'DELETE' }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger DELETE webhook with custom headers', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.delete, httpMethod: 'DELETE', headers: { 'X-Delete-Reason': 'cleanup', 'Authorization': 'Bearer token' } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); it('should trigger DELETE webhook without parameters', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.delete, httpMethod: 'DELETE' }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); }); // ====================================================================== // Error Handling // ====================================================================== describe('Error Handling', () => { it('should handle invalid webhook URL', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: 'https://invalid-url.example.com/webhook/nonexistent', httpMethod: 'GET' }, mcpContext ); expect(response.success).toBe(false); expect(response.error).toBeDefined(); }); it('should handle malformed webhook URL', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: 'not-a-valid-url', httpMethod: 'GET' }, mcpContext ); expect(response.success).toBe(false); expect(response.error).toBeDefined(); }); it('should handle missing webhook URL', async () => { const response = await handleTriggerWebhookWorkflow( { httpMethod: 'GET' } as any, mcpContext ); expect(response.success).toBe(false); expect(response.error).toBeDefined(); }); it('should handle invalid HTTP method', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.get, httpMethod: 'INVALID' as any }, mcpContext ); expect(response.success).toBe(false); expect(response.error).toBeDefined(); }); }); // ====================================================================== // Default Method (POST) // ====================================================================== describe('Default Method Behavior', () => { it('should default to POST method when not specified', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.post, data: { defaultMethod: true } }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); }); }); // ====================================================================== // Response Format Verification // ====================================================================== describe('Response Format', () => { it('should return complete webhook response structure', async () => { const response = await handleTriggerWebhookWorkflow( { webhookUrl: webhookUrls.get, httpMethod: 'GET', waitForResponse: true }, mcpContext ); expect(response.success).toBe(true); expect(response.data).toBeDefined(); expect(response.message).toBeDefined(); expect(response.message).toContain('Webhook triggered successfully'); // Response data should be defined (either workflow output or execution info) expect(typeof response.data).not.toBe('undefined'); }); }); }); ```