This is page 54 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── 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 -------------------------------------------------------------------------------- /src/mcp/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 3 | import { 4 | CallToolRequestSchema, 5 | ListToolsRequestSchema, 6 | InitializeRequestSchema, 7 | } from '@modelcontextprotocol/sdk/types.js'; 8 | import { existsSync, promises as fs } from 'fs'; 9 | import path from 'path'; 10 | import { n8nDocumentationToolsFinal } from './tools'; 11 | import { n8nManagementTools } from './tools-n8n-manager'; 12 | import { makeToolsN8nFriendly } from './tools-n8n-friendly'; 13 | import { getWorkflowExampleString } from './workflow-examples'; 14 | import { logger } from '../utils/logger'; 15 | import { NodeRepository } from '../database/node-repository'; 16 | import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter'; 17 | import { PropertyFilter } from '../services/property-filter'; 18 | import { TaskTemplates } from '../services/task-templates'; 19 | import { ConfigValidator } from '../services/config-validator'; 20 | import { EnhancedConfigValidator, ValidationMode, ValidationProfile } from '../services/enhanced-config-validator'; 21 | import { PropertyDependencies } from '../services/property-dependencies'; 22 | import { SimpleCache } from '../utils/simple-cache'; 23 | import { TemplateService } from '../templates/template-service'; 24 | import { WorkflowValidator } from '../services/workflow-validator'; 25 | import { isN8nApiConfigured } from '../config/n8n-api'; 26 | import * as n8nHandlers from './handlers-n8n-manager'; 27 | import { handleUpdatePartialWorkflow } from './handlers-workflow-diff'; 28 | import { getToolDocumentation, getToolsOverview } from './tools-documentation'; 29 | import { PROJECT_VERSION } from '../utils/version'; 30 | import { getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils'; 31 | import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; 32 | import { ToolValidation, Validator, ValidationError } from '../utils/validation-schemas'; 33 | import { 34 | negotiateProtocolVersion, 35 | logProtocolNegotiation, 36 | STANDARD_PROTOCOL_VERSION 37 | } from '../utils/protocol-version'; 38 | import { InstanceContext } from '../types/instance-context'; 39 | import { telemetry } from '../telemetry'; 40 | import { EarlyErrorLogger } from '../telemetry/early-error-logger'; 41 | import { STARTUP_CHECKPOINTS } from '../telemetry/startup-checkpoints'; 42 | 43 | interface NodeRow { 44 | node_type: string; 45 | package_name: string; 46 | display_name: string; 47 | description?: string; 48 | category?: string; 49 | development_style?: string; 50 | is_ai_tool: number; 51 | is_trigger: number; 52 | is_webhook: number; 53 | is_versioned: number; 54 | version?: string; 55 | documentation?: string; 56 | properties_schema?: string; 57 | operations?: string; 58 | credentials_required?: string; 59 | } 60 | 61 | export class N8NDocumentationMCPServer { 62 | private server: Server; 63 | private db: DatabaseAdapter | null = null; 64 | private repository: NodeRepository | null = null; 65 | private templateService: TemplateService | null = null; 66 | private initialized: Promise<void>; 67 | private cache = new SimpleCache(); 68 | private clientInfo: any = null; 69 | private instanceContext?: InstanceContext; 70 | private previousTool: string | null = null; 71 | private previousToolTimestamp: number = Date.now(); 72 | private earlyLogger: EarlyErrorLogger | null = null; 73 | 74 | constructor(instanceContext?: InstanceContext, earlyLogger?: EarlyErrorLogger) { 75 | this.instanceContext = instanceContext; 76 | this.earlyLogger = earlyLogger || null; 77 | // Check for test environment first 78 | const envDbPath = process.env.NODE_DB_PATH; 79 | let dbPath: string | null = null; 80 | 81 | let possiblePaths: string[] = []; 82 | 83 | if (envDbPath && (envDbPath === ':memory:' || existsSync(envDbPath))) { 84 | dbPath = envDbPath; 85 | } else { 86 | // Try multiple database paths 87 | possiblePaths = [ 88 | path.join(process.cwd(), 'data', 'nodes.db'), 89 | path.join(__dirname, '../../data', 'nodes.db'), 90 | './data/nodes.db' 91 | ]; 92 | 93 | for (const p of possiblePaths) { 94 | if (existsSync(p)) { 95 | dbPath = p; 96 | break; 97 | } 98 | } 99 | } 100 | 101 | if (!dbPath) { 102 | logger.error('Database not found in any of the expected locations:', possiblePaths); 103 | throw new Error('Database nodes.db not found. Please run npm run rebuild first.'); 104 | } 105 | 106 | // Initialize database asynchronously 107 | this.initialized = this.initializeDatabase(dbPath).then(() => { 108 | // After database is ready, check n8n API configuration (v2.18.3) 109 | if (this.earlyLogger) { 110 | this.earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.N8N_API_CHECKING); 111 | } 112 | 113 | // Log n8n API configuration status at startup 114 | const apiConfigured = isN8nApiConfigured(); 115 | const totalTools = apiConfigured ? 116 | n8nDocumentationToolsFinal.length + n8nManagementTools.length : 117 | n8nDocumentationToolsFinal.length; 118 | 119 | logger.info(`MCP server initialized with ${totalTools} tools (n8n API: ${apiConfigured ? 'configured' : 'not configured'})`); 120 | 121 | if (this.earlyLogger) { 122 | this.earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.N8N_API_READY); 123 | } 124 | }); 125 | 126 | logger.info('Initializing n8n Documentation MCP server'); 127 | 128 | this.server = new Server( 129 | { 130 | name: 'n8n-documentation-mcp', 131 | version: '1.0.0', 132 | }, 133 | { 134 | capabilities: { 135 | tools: {}, 136 | }, 137 | } 138 | ); 139 | 140 | this.setupHandlers(); 141 | } 142 | 143 | private async initializeDatabase(dbPath: string): Promise<void> { 144 | try { 145 | // Checkpoint: Database connecting (v2.18.3) 146 | if (this.earlyLogger) { 147 | this.earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.DATABASE_CONNECTING); 148 | } 149 | 150 | logger.debug('Database initialization starting...', { dbPath }); 151 | 152 | this.db = await createDatabaseAdapter(dbPath); 153 | logger.debug('Database adapter created'); 154 | 155 | // If using in-memory database for tests, initialize schema 156 | if (dbPath === ':memory:') { 157 | await this.initializeInMemorySchema(); 158 | logger.debug('In-memory schema initialized'); 159 | } 160 | 161 | this.repository = new NodeRepository(this.db); 162 | logger.debug('Node repository initialized'); 163 | 164 | this.templateService = new TemplateService(this.db); 165 | logger.debug('Template service initialized'); 166 | 167 | // Initialize similarity services for enhanced validation 168 | EnhancedConfigValidator.initializeSimilarityServices(this.repository); 169 | logger.debug('Similarity services initialized'); 170 | 171 | // Checkpoint: Database connected (v2.18.3) 172 | if (this.earlyLogger) { 173 | this.earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.DATABASE_CONNECTED); 174 | } 175 | 176 | logger.info(`Database initialized successfully from: ${dbPath}`); 177 | } catch (error) { 178 | logger.error('Failed to initialize database:', error); 179 | throw new Error(`Failed to open database: ${error instanceof Error ? error.message : 'Unknown error'}`); 180 | } 181 | } 182 | 183 | private async initializeInMemorySchema(): Promise<void> { 184 | if (!this.db) return; 185 | 186 | // Read and execute schema 187 | const schemaPath = path.join(__dirname, '../../src/database/schema.sql'); 188 | const schema = await fs.readFile(schemaPath, 'utf-8'); 189 | 190 | // Parse SQL statements properly (handles BEGIN...END blocks in triggers) 191 | const statements = this.parseSQLStatements(schema); 192 | 193 | for (const statement of statements) { 194 | if (statement.trim()) { 195 | try { 196 | this.db.exec(statement); 197 | } catch (error) { 198 | logger.error(`Failed to execute SQL statement: ${statement.substring(0, 100)}...`, error); 199 | throw error; 200 | } 201 | } 202 | } 203 | } 204 | 205 | /** 206 | * Parse SQL statements from schema file, properly handling multi-line statements 207 | * including triggers with BEGIN...END blocks 208 | */ 209 | private parseSQLStatements(sql: string): string[] { 210 | const statements: string[] = []; 211 | let current = ''; 212 | let inBlock = false; 213 | 214 | const lines = sql.split('\n'); 215 | 216 | for (const line of lines) { 217 | const trimmed = line.trim().toUpperCase(); 218 | 219 | // Skip comments and empty lines 220 | if (trimmed.startsWith('--') || trimmed === '') { 221 | continue; 222 | } 223 | 224 | // Track BEGIN...END blocks (triggers, procedures) 225 | if (trimmed.includes('BEGIN')) { 226 | inBlock = true; 227 | } 228 | 229 | current += line + '\n'; 230 | 231 | // End of block (trigger/procedure) 232 | if (inBlock && trimmed === 'END;') { 233 | statements.push(current.trim()); 234 | current = ''; 235 | inBlock = false; 236 | continue; 237 | } 238 | 239 | // Regular statement end (not in block) 240 | if (!inBlock && trimmed.endsWith(';')) { 241 | statements.push(current.trim()); 242 | current = ''; 243 | } 244 | } 245 | 246 | // Add any remaining content 247 | if (current.trim()) { 248 | statements.push(current.trim()); 249 | } 250 | 251 | return statements.filter(s => s.length > 0); 252 | } 253 | 254 | private async ensureInitialized(): Promise<void> { 255 | await this.initialized; 256 | if (!this.db || !this.repository) { 257 | throw new Error('Database not initialized'); 258 | } 259 | 260 | // Validate database health on first access 261 | if (!this.dbHealthChecked) { 262 | await this.validateDatabaseHealth(); 263 | this.dbHealthChecked = true; 264 | } 265 | } 266 | 267 | private dbHealthChecked: boolean = false; 268 | 269 | private async validateDatabaseHealth(): Promise<void> { 270 | if (!this.db) return; 271 | 272 | try { 273 | // Check if nodes table has data 274 | const nodeCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as { count: number }; 275 | 276 | if (nodeCount.count === 0) { 277 | logger.error('CRITICAL: Database is empty - no nodes found! Please run: npm run rebuild'); 278 | throw new Error('Database is empty. Run "npm run rebuild" to populate node data.'); 279 | } 280 | 281 | // Check if FTS5 table exists 282 | const ftsExists = this.db.prepare(` 283 | SELECT name FROM sqlite_master 284 | WHERE type='table' AND name='nodes_fts' 285 | `).get(); 286 | 287 | if (!ftsExists) { 288 | logger.warn('FTS5 table missing - search performance will be degraded. Please run: npm run rebuild'); 289 | } else { 290 | const ftsCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes_fts').get() as { count: number }; 291 | if (ftsCount.count === 0) { 292 | logger.warn('FTS5 index is empty - search will not work properly. Please run: npm run rebuild'); 293 | } 294 | } 295 | 296 | logger.info(`Database health check passed: ${nodeCount.count} nodes loaded`); 297 | } catch (error) { 298 | logger.error('Database health check failed:', error); 299 | throw error; 300 | } 301 | } 302 | 303 | private setupHandlers(): void { 304 | // Handle initialization 305 | this.server.setRequestHandler(InitializeRequestSchema, async (request) => { 306 | const clientVersion = request.params.protocolVersion; 307 | const clientCapabilities = request.params.capabilities; 308 | const clientInfo = request.params.clientInfo; 309 | 310 | logger.info('MCP Initialize request received', { 311 | clientVersion, 312 | clientCapabilities, 313 | clientInfo 314 | }); 315 | 316 | // Track session start 317 | telemetry.trackSessionStart(); 318 | 319 | // Store client info for later use 320 | this.clientInfo = clientInfo; 321 | 322 | // Negotiate protocol version based on client information 323 | const negotiationResult = negotiateProtocolVersion( 324 | clientVersion, 325 | clientInfo, 326 | undefined, // no user agent in MCP protocol 327 | undefined // no headers in MCP protocol 328 | ); 329 | 330 | logProtocolNegotiation(negotiationResult, logger, 'MCP_INITIALIZE'); 331 | 332 | // Warn if there's a version mismatch (for debugging) 333 | if (clientVersion && clientVersion !== negotiationResult.version) { 334 | logger.warn(`Protocol version negotiated: client requested ${clientVersion}, server will use ${negotiationResult.version}`, { 335 | reasoning: negotiationResult.reasoning 336 | }); 337 | } 338 | 339 | const response = { 340 | protocolVersion: negotiationResult.version, 341 | capabilities: { 342 | tools: {}, 343 | }, 344 | serverInfo: { 345 | name: 'n8n-documentation-mcp', 346 | version: PROJECT_VERSION, 347 | }, 348 | }; 349 | 350 | logger.info('MCP Initialize response', { response }); 351 | return response; 352 | }); 353 | 354 | // Handle tool listing 355 | this.server.setRequestHandler(ListToolsRequestSchema, async (request) => { 356 | // Combine documentation tools with management tools if API is configured 357 | let tools = [...n8nDocumentationToolsFinal]; 358 | 359 | // Check if n8n API tools should be available 360 | // 1. Environment variables (backward compatibility) 361 | // 2. Instance context (multi-tenant support) 362 | // 3. Multi-tenant mode enabled (always show tools, runtime checks will handle auth) 363 | const hasEnvConfig = isN8nApiConfigured(); 364 | const hasInstanceConfig = !!(this.instanceContext?.n8nApiUrl && this.instanceContext?.n8nApiKey); 365 | const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true'; 366 | 367 | const shouldIncludeManagementTools = hasEnvConfig || hasInstanceConfig || isMultiTenantEnabled; 368 | 369 | if (shouldIncludeManagementTools) { 370 | tools.push(...n8nManagementTools); 371 | logger.debug(`Tool listing: ${tools.length} tools available (${n8nDocumentationToolsFinal.length} documentation + ${n8nManagementTools.length} management)`, { 372 | hasEnvConfig, 373 | hasInstanceConfig, 374 | isMultiTenantEnabled 375 | }); 376 | } else { 377 | logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`, { 378 | hasEnvConfig, 379 | hasInstanceConfig, 380 | isMultiTenantEnabled 381 | }); 382 | } 383 | 384 | // Check if client is n8n (from initialization) 385 | const clientInfo = this.clientInfo; 386 | const isN8nClient = clientInfo?.name?.includes('n8n') || 387 | clientInfo?.name?.includes('langchain'); 388 | 389 | if (isN8nClient) { 390 | logger.info('Detected n8n client, using n8n-friendly tool descriptions'); 391 | tools = makeToolsN8nFriendly(tools); 392 | } 393 | 394 | // Log validation tools' input schemas for debugging 395 | const validationTools = tools.filter(t => t.name.startsWith('validate_')); 396 | validationTools.forEach(tool => { 397 | logger.info('Validation tool schema', { 398 | toolName: tool.name, 399 | inputSchema: JSON.stringify(tool.inputSchema, null, 2), 400 | hasOutputSchema: !!tool.outputSchema, 401 | description: tool.description 402 | }); 403 | }); 404 | 405 | return { tools }; 406 | }); 407 | 408 | // Handle tool execution 409 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 410 | const { name, arguments: args } = request.params; 411 | 412 | // Enhanced logging for debugging tool calls 413 | logger.info('Tool call received - DETAILED DEBUG', { 414 | toolName: name, 415 | arguments: JSON.stringify(args, null, 2), 416 | argumentsType: typeof args, 417 | argumentsKeys: args ? Object.keys(args) : [], 418 | hasNodeType: args && 'nodeType' in args, 419 | hasConfig: args && 'config' in args, 420 | configType: args && args.config ? typeof args.config : 'N/A', 421 | rawRequest: JSON.stringify(request.params) 422 | }); 423 | 424 | // Workaround for n8n's nested output bug 425 | // Check if args contains nested 'output' structure from n8n's memory corruption 426 | let processedArgs = args; 427 | if (args && typeof args === 'object' && 'output' in args) { 428 | try { 429 | const possibleNestedData = args.output; 430 | // If output is a string that looks like JSON, try to parse it 431 | if (typeof possibleNestedData === 'string' && possibleNestedData.trim().startsWith('{')) { 432 | const parsed = JSON.parse(possibleNestedData); 433 | if (parsed && typeof parsed === 'object') { 434 | logger.warn('Detected n8n nested output bug, attempting to extract actual arguments', { 435 | originalArgs: args, 436 | extractedArgs: parsed 437 | }); 438 | 439 | // Validate the extracted arguments match expected tool schema 440 | if (this.validateExtractedArgs(name, parsed)) { 441 | // Use the extracted data as args 442 | processedArgs = parsed; 443 | } else { 444 | logger.warn('Extracted arguments failed validation, using original args', { 445 | toolName: name, 446 | extractedArgs: parsed 447 | }); 448 | } 449 | } 450 | } 451 | } catch (parseError) { 452 | logger.debug('Failed to parse nested output, continuing with original args', { 453 | error: parseError instanceof Error ? parseError.message : String(parseError) 454 | }); 455 | } 456 | } 457 | 458 | try { 459 | logger.debug(`Executing tool: ${name}`, { args: processedArgs }); 460 | const startTime = Date.now(); 461 | const result = await this.executeTool(name, processedArgs); 462 | const duration = Date.now() - startTime; 463 | logger.debug(`Tool ${name} executed successfully`); 464 | 465 | // Track tool usage and sequence 466 | telemetry.trackToolUsage(name, true, duration); 467 | 468 | // Track tool sequence if there was a previous tool 469 | if (this.previousTool) { 470 | const timeDelta = Date.now() - this.previousToolTimestamp; 471 | telemetry.trackToolSequence(this.previousTool, name, timeDelta); 472 | } 473 | 474 | // Update previous tool tracking 475 | this.previousTool = name; 476 | this.previousToolTimestamp = Date.now(); 477 | 478 | // Ensure the result is properly formatted for MCP 479 | let responseText: string; 480 | let structuredContent: any = null; 481 | 482 | try { 483 | // For validation tools, check if we should use structured content 484 | if (name.startsWith('validate_') && typeof result === 'object' && result !== null) { 485 | // Clean up the result to ensure it matches the outputSchema 486 | const cleanResult = this.sanitizeValidationResult(result, name); 487 | structuredContent = cleanResult; 488 | responseText = JSON.stringify(cleanResult, null, 2); 489 | } else { 490 | responseText = typeof result === 'string' ? result : JSON.stringify(result, null, 2); 491 | } 492 | } catch (jsonError) { 493 | logger.warn(`Failed to stringify tool result for ${name}:`, jsonError); 494 | responseText = String(result); 495 | } 496 | 497 | // Validate response size (n8n might have limits) 498 | if (responseText.length > 1000000) { // 1MB limit 499 | logger.warn(`Tool ${name} response is very large (${responseText.length} chars), truncating`); 500 | responseText = responseText.substring(0, 999000) + '\n\n[Response truncated due to size limits]'; 501 | structuredContent = null; // Don't use structured content for truncated responses 502 | } 503 | 504 | // Build MCP response with strict schema compliance 505 | const mcpResponse: any = { 506 | content: [ 507 | { 508 | type: 'text' as const, 509 | text: responseText, 510 | }, 511 | ], 512 | }; 513 | 514 | // For tools with outputSchema, structuredContent is REQUIRED by MCP spec 515 | if (name.startsWith('validate_') && structuredContent !== null) { 516 | mcpResponse.structuredContent = structuredContent; 517 | } 518 | 519 | return mcpResponse; 520 | } catch (error) { 521 | logger.error(`Error executing tool ${name}`, error); 522 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 523 | 524 | // Track tool error 525 | telemetry.trackToolUsage(name, false); 526 | telemetry.trackError( 527 | error instanceof Error ? error.constructor.name : 'UnknownError', 528 | `tool_execution`, 529 | name, 530 | errorMessage 531 | ); 532 | 533 | // Track tool sequence even for errors 534 | if (this.previousTool) { 535 | const timeDelta = Date.now() - this.previousToolTimestamp; 536 | telemetry.trackToolSequence(this.previousTool, name, timeDelta); 537 | } 538 | 539 | // Update previous tool tracking (even for failed tools) 540 | this.previousTool = name; 541 | this.previousToolTimestamp = Date.now(); 542 | 543 | // Provide more helpful error messages for common n8n issues 544 | let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`; 545 | 546 | if (errorMessage.includes('required') || errorMessage.includes('missing')) { 547 | helpfulMessage += '\n\nNote: This error often occurs when the AI agent sends incomplete or incorrectly formatted parameters. Please ensure all required fields are provided with the correct types.'; 548 | } else if (errorMessage.includes('type') || errorMessage.includes('expected')) { 549 | helpfulMessage += '\n\nNote: This error indicates a type mismatch. The AI agent may be sending data in the wrong format (e.g., string instead of object).'; 550 | } else if (errorMessage.includes('Unknown category') || errorMessage.includes('not found')) { 551 | helpfulMessage += '\n\nNote: The requested resource or category was not found. Please check the available options.'; 552 | } 553 | 554 | // For n8n schema errors, add specific guidance 555 | if (name.startsWith('validate_') && (errorMessage.includes('config') || errorMessage.includes('nodeType'))) { 556 | helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})'; 557 | } 558 | 559 | return { 560 | content: [ 561 | { 562 | type: 'text', 563 | text: helpfulMessage, 564 | }, 565 | ], 566 | isError: true, 567 | }; 568 | } 569 | }); 570 | } 571 | 572 | /** 573 | * Sanitize validation result to match outputSchema 574 | */ 575 | private sanitizeValidationResult(result: any, toolName: string): any { 576 | if (!result || typeof result !== 'object') { 577 | return result; 578 | } 579 | 580 | const sanitized = { ...result }; 581 | 582 | // Ensure required fields exist with proper types and filter to schema-defined fields only 583 | if (toolName === 'validate_node_minimal') { 584 | // Filter to only schema-defined fields 585 | const filtered = { 586 | nodeType: String(sanitized.nodeType || ''), 587 | displayName: String(sanitized.displayName || ''), 588 | valid: Boolean(sanitized.valid), 589 | missingRequiredFields: Array.isArray(sanitized.missingRequiredFields) 590 | ? sanitized.missingRequiredFields.map(String) 591 | : [] 592 | }; 593 | return filtered; 594 | } else if (toolName === 'validate_node_operation') { 595 | // Ensure summary exists 596 | let summary = sanitized.summary; 597 | if (!summary || typeof summary !== 'object') { 598 | summary = { 599 | hasErrors: Array.isArray(sanitized.errors) ? sanitized.errors.length > 0 : false, 600 | errorCount: Array.isArray(sanitized.errors) ? sanitized.errors.length : 0, 601 | warningCount: Array.isArray(sanitized.warnings) ? sanitized.warnings.length : 0, 602 | suggestionCount: Array.isArray(sanitized.suggestions) ? sanitized.suggestions.length : 0 603 | }; 604 | } 605 | 606 | // Filter to only schema-defined fields 607 | const filtered = { 608 | nodeType: String(sanitized.nodeType || ''), 609 | workflowNodeType: String(sanitized.workflowNodeType || sanitized.nodeType || ''), 610 | displayName: String(sanitized.displayName || ''), 611 | valid: Boolean(sanitized.valid), 612 | errors: Array.isArray(sanitized.errors) ? sanitized.errors : [], 613 | warnings: Array.isArray(sanitized.warnings) ? sanitized.warnings : [], 614 | suggestions: Array.isArray(sanitized.suggestions) ? sanitized.suggestions : [], 615 | summary: summary 616 | }; 617 | return filtered; 618 | } else if (toolName.startsWith('validate_workflow')) { 619 | sanitized.valid = Boolean(sanitized.valid); 620 | 621 | // Ensure arrays exist 622 | sanitized.errors = Array.isArray(sanitized.errors) ? sanitized.errors : []; 623 | sanitized.warnings = Array.isArray(sanitized.warnings) ? sanitized.warnings : []; 624 | 625 | // Ensure statistics/summary exists 626 | if (toolName === 'validate_workflow') { 627 | if (!sanitized.summary || typeof sanitized.summary !== 'object') { 628 | sanitized.summary = { 629 | totalNodes: 0, 630 | enabledNodes: 0, 631 | triggerNodes: 0, 632 | validConnections: 0, 633 | invalidConnections: 0, 634 | expressionsValidated: 0, 635 | errorCount: sanitized.errors.length, 636 | warningCount: sanitized.warnings.length 637 | }; 638 | } 639 | } else { 640 | if (!sanitized.statistics || typeof sanitized.statistics !== 'object') { 641 | sanitized.statistics = { 642 | totalNodes: 0, 643 | triggerNodes: 0, 644 | validConnections: 0, 645 | invalidConnections: 0, 646 | expressionsValidated: 0 647 | }; 648 | } 649 | } 650 | } 651 | 652 | // Remove undefined values to ensure clean JSON 653 | return JSON.parse(JSON.stringify(sanitized)); 654 | } 655 | 656 | /** 657 | * Enhanced parameter validation using schemas 658 | */ 659 | private validateToolParams(toolName: string, args: any, legacyRequiredParams?: string[]): void { 660 | try { 661 | // If legacy required params are provided, use the new validation but fall back to basic if needed 662 | let validationResult; 663 | 664 | switch (toolName) { 665 | case 'validate_node_operation': 666 | validationResult = ToolValidation.validateNodeOperation(args); 667 | break; 668 | case 'validate_node_minimal': 669 | validationResult = ToolValidation.validateNodeMinimal(args); 670 | break; 671 | case 'validate_workflow': 672 | case 'validate_workflow_connections': 673 | case 'validate_workflow_expressions': 674 | validationResult = ToolValidation.validateWorkflow(args); 675 | break; 676 | case 'search_nodes': 677 | validationResult = ToolValidation.validateSearchNodes(args); 678 | break; 679 | case 'list_node_templates': 680 | validationResult = ToolValidation.validateListNodeTemplates(args); 681 | break; 682 | case 'n8n_create_workflow': 683 | validationResult = ToolValidation.validateCreateWorkflow(args); 684 | break; 685 | case 'n8n_get_workflow': 686 | case 'n8n_get_workflow_details': 687 | case 'n8n_get_workflow_structure': 688 | case 'n8n_get_workflow_minimal': 689 | case 'n8n_update_full_workflow': 690 | case 'n8n_delete_workflow': 691 | case 'n8n_validate_workflow': 692 | case 'n8n_autofix_workflow': 693 | case 'n8n_get_execution': 694 | case 'n8n_delete_execution': 695 | validationResult = ToolValidation.validateWorkflowId(args); 696 | break; 697 | default: 698 | // For tools not yet migrated to schema validation, use basic validation 699 | return this.validateToolParamsBasic(toolName, args, legacyRequiredParams || []); 700 | } 701 | 702 | if (!validationResult.valid) { 703 | const errorMessage = Validator.formatErrors(validationResult, toolName); 704 | logger.error(`Parameter validation failed for ${toolName}:`, errorMessage); 705 | throw new ValidationError(errorMessage); 706 | } 707 | } catch (error) { 708 | // Handle validation errors properly 709 | if (error instanceof ValidationError) { 710 | throw error; // Re-throw validation errors as-is 711 | } 712 | 713 | // Handle unexpected errors from validation system 714 | logger.error(`Validation system error for ${toolName}:`, error); 715 | 716 | // Provide a user-friendly error message 717 | const errorMessage = error instanceof Error 718 | ? `Internal validation error: ${error.message}` 719 | : `Internal validation error while processing ${toolName}`; 720 | 721 | throw new Error(errorMessage); 722 | } 723 | } 724 | 725 | /** 726 | * Legacy parameter validation (fallback) 727 | */ 728 | private validateToolParamsBasic(toolName: string, args: any, requiredParams: string[]): void { 729 | const missing: string[] = []; 730 | const invalid: string[] = []; 731 | 732 | for (const param of requiredParams) { 733 | if (!(param in args) || args[param] === undefined || args[param] === null) { 734 | missing.push(param); 735 | } else if (typeof args[param] === 'string' && args[param].trim() === '') { 736 | invalid.push(`${param} (empty string)`); 737 | } 738 | } 739 | 740 | if (missing.length > 0) { 741 | throw new Error(`Missing required parameters for ${toolName}: ${missing.join(', ')}. Please provide the required parameters to use this tool.`); 742 | } 743 | 744 | if (invalid.length > 0) { 745 | throw new Error(`Invalid parameters for ${toolName}: ${invalid.join(', ')}. String parameters cannot be empty.`); 746 | } 747 | } 748 | 749 | /** 750 | * Validate extracted arguments match expected tool schema 751 | */ 752 | private validateExtractedArgs(toolName: string, args: any): boolean { 753 | if (!args || typeof args !== 'object') { 754 | return false; 755 | } 756 | 757 | // Get all available tools 758 | const allTools = [...n8nDocumentationToolsFinal, ...n8nManagementTools]; 759 | const tool = allTools.find(t => t.name === toolName); 760 | if (!tool || !tool.inputSchema) { 761 | return true; // If no schema, assume valid 762 | } 763 | 764 | const schema = tool.inputSchema; 765 | const required = schema.required || []; 766 | const properties = schema.properties || {}; 767 | 768 | // Check all required fields are present 769 | for (const requiredField of required) { 770 | if (!(requiredField in args)) { 771 | logger.debug(`Extracted args missing required field: ${requiredField}`, { 772 | toolName, 773 | extractedArgs: args, 774 | required 775 | }); 776 | return false; 777 | } 778 | } 779 | 780 | // Check field types match schema 781 | for (const [fieldName, fieldValue] of Object.entries(args)) { 782 | if (properties[fieldName]) { 783 | const expectedType = properties[fieldName].type; 784 | const actualType = Array.isArray(fieldValue) ? 'array' : typeof fieldValue; 785 | 786 | // Basic type validation 787 | if (expectedType && expectedType !== actualType) { 788 | // Special case: number can be coerced from string 789 | if (expectedType === 'number' && actualType === 'string' && !isNaN(Number(fieldValue))) { 790 | continue; 791 | } 792 | 793 | logger.debug(`Extracted args field type mismatch: ${fieldName}`, { 794 | toolName, 795 | expectedType, 796 | actualType, 797 | fieldValue 798 | }); 799 | return false; 800 | } 801 | } 802 | } 803 | 804 | // Check for extraneous fields if additionalProperties is false 805 | if (schema.additionalProperties === false) { 806 | const allowedFields = Object.keys(properties); 807 | const extraFields = Object.keys(args).filter(field => !allowedFields.includes(field)); 808 | 809 | if (extraFields.length > 0) { 810 | logger.debug(`Extracted args have extra fields`, { 811 | toolName, 812 | extraFields, 813 | allowedFields 814 | }); 815 | // For n8n compatibility, we'll still consider this valid but log it 816 | } 817 | } 818 | 819 | return true; 820 | } 821 | 822 | async executeTool(name: string, args: any): Promise<any> { 823 | // Ensure args is an object and validate it 824 | args = args || {}; 825 | 826 | // Log the tool call for debugging n8n issues 827 | logger.info(`Tool execution: ${name}`, { 828 | args: typeof args === 'object' ? JSON.stringify(args) : args, 829 | argsType: typeof args, 830 | argsKeys: typeof args === 'object' ? Object.keys(args) : 'not-object' 831 | }); 832 | 833 | // Validate that args is actually an object 834 | if (typeof args !== 'object' || args === null) { 835 | throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`); 836 | } 837 | 838 | switch (name) { 839 | case 'tools_documentation': 840 | // No required parameters 841 | return this.getToolsDocumentation(args.topic, args.depth); 842 | case 'list_nodes': 843 | // No required parameters 844 | return this.listNodes(args); 845 | case 'get_node_info': 846 | this.validateToolParams(name, args, ['nodeType']); 847 | return this.getNodeInfo(args.nodeType); 848 | case 'search_nodes': 849 | this.validateToolParams(name, args, ['query']); 850 | // Convert limit to number if provided, otherwise use default 851 | const limit = args.limit !== undefined ? Number(args.limit) || 20 : 20; 852 | return this.searchNodes(args.query, limit, { mode: args.mode, includeExamples: args.includeExamples }); 853 | case 'list_ai_tools': 854 | // No required parameters 855 | return this.listAITools(); 856 | case 'get_node_documentation': 857 | this.validateToolParams(name, args, ['nodeType']); 858 | return this.getNodeDocumentation(args.nodeType); 859 | case 'get_database_statistics': 860 | // No required parameters 861 | return this.getDatabaseStatistics(); 862 | case 'get_node_essentials': 863 | this.validateToolParams(name, args, ['nodeType']); 864 | return this.getNodeEssentials(args.nodeType, args.includeExamples); 865 | case 'search_node_properties': 866 | this.validateToolParams(name, args, ['nodeType', 'query']); 867 | const maxResults = args.maxResults !== undefined ? Number(args.maxResults) || 20 : 20; 868 | return this.searchNodeProperties(args.nodeType, args.query, maxResults); 869 | case 'list_tasks': 870 | // No required parameters 871 | return this.listTasks(args.category); 872 | case 'validate_node_operation': 873 | this.validateToolParams(name, args, ['nodeType', 'config']); 874 | // Ensure config is an object 875 | if (typeof args.config !== 'object' || args.config === null) { 876 | logger.warn(`validate_node_operation called with invalid config type: ${typeof args.config}`); 877 | return { 878 | nodeType: args.nodeType || 'unknown', 879 | workflowNodeType: args.nodeType || 'unknown', 880 | displayName: 'Unknown Node', 881 | valid: false, 882 | errors: [{ 883 | type: 'config', 884 | property: 'config', 885 | message: 'Invalid config format - expected object', 886 | fix: 'Provide config as an object with node properties' 887 | }], 888 | warnings: [], 889 | suggestions: [ 890 | '🔧 RECOVERY: Invalid config detected. Fix with:', 891 | ' • Ensure config is an object: { "resource": "...", "operation": "..." }', 892 | ' • Use get_node_essentials to see required fields for this node type', 893 | ' • Check if the node type is correct before configuring it' 894 | ], 895 | summary: { 896 | hasErrors: true, 897 | errorCount: 1, 898 | warningCount: 0, 899 | suggestionCount: 3 900 | } 901 | }; 902 | } 903 | return this.validateNodeConfig(args.nodeType, args.config, 'operation', args.profile); 904 | case 'validate_node_minimal': 905 | this.validateToolParams(name, args, ['nodeType', 'config']); 906 | // Ensure config is an object 907 | if (typeof args.config !== 'object' || args.config === null) { 908 | logger.warn(`validate_node_minimal called with invalid config type: ${typeof args.config}`); 909 | return { 910 | nodeType: args.nodeType || 'unknown', 911 | displayName: 'Unknown Node', 912 | valid: false, 913 | missingRequiredFields: [ 914 | 'Invalid config format - expected object', 915 | '🔧 RECOVERY: Use format { "resource": "...", "operation": "..." } or {} for empty config' 916 | ] 917 | }; 918 | } 919 | return this.validateNodeMinimal(args.nodeType, args.config); 920 | case 'get_property_dependencies': 921 | this.validateToolParams(name, args, ['nodeType']); 922 | return this.getPropertyDependencies(args.nodeType, args.config); 923 | case 'get_node_as_tool_info': 924 | this.validateToolParams(name, args, ['nodeType']); 925 | return this.getNodeAsToolInfo(args.nodeType); 926 | case 'list_templates': 927 | // No required params 928 | const listLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); 929 | const listOffset = Math.max(Number(args.offset) || 0, 0); 930 | const sortBy = args.sortBy || 'views'; 931 | const includeMetadata = Boolean(args.includeMetadata); 932 | return this.listTemplates(listLimit, listOffset, sortBy, includeMetadata); 933 | case 'list_node_templates': 934 | this.validateToolParams(name, args, ['nodeTypes']); 935 | const templateLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); 936 | const templateOffset = Math.max(Number(args.offset) || 0, 0); 937 | return this.listNodeTemplates(args.nodeTypes, templateLimit, templateOffset); 938 | case 'get_template': 939 | this.validateToolParams(name, args, ['templateId']); 940 | const templateId = Number(args.templateId); 941 | const mode = args.mode || 'full'; 942 | return this.getTemplate(templateId, mode); 943 | case 'search_templates': 944 | this.validateToolParams(name, args, ['query']); 945 | const searchLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100); 946 | const searchOffset = Math.max(Number(args.offset) || 0, 0); 947 | const searchFields = args.fields as string[] | undefined; 948 | return this.searchTemplates(args.query, searchLimit, searchOffset, searchFields); 949 | case 'get_templates_for_task': 950 | this.validateToolParams(name, args, ['task']); 951 | const taskLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); 952 | const taskOffset = Math.max(Number(args.offset) || 0, 0); 953 | return this.getTemplatesForTask(args.task, taskLimit, taskOffset); 954 | case 'search_templates_by_metadata': 955 | // No required params - all filters are optional 956 | const metadataLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100); 957 | const metadataOffset = Math.max(Number(args.offset) || 0, 0); 958 | return this.searchTemplatesByMetadata({ 959 | category: args.category, 960 | complexity: args.complexity, 961 | maxSetupMinutes: args.maxSetupMinutes ? Number(args.maxSetupMinutes) : undefined, 962 | minSetupMinutes: args.minSetupMinutes ? Number(args.minSetupMinutes) : undefined, 963 | requiredService: args.requiredService, 964 | targetAudience: args.targetAudience 965 | }, metadataLimit, metadataOffset); 966 | case 'validate_workflow': 967 | this.validateToolParams(name, args, ['workflow']); 968 | return this.validateWorkflow(args.workflow, args.options); 969 | case 'validate_workflow_connections': 970 | this.validateToolParams(name, args, ['workflow']); 971 | return this.validateWorkflowConnections(args.workflow); 972 | case 'validate_workflow_expressions': 973 | this.validateToolParams(name, args, ['workflow']); 974 | return this.validateWorkflowExpressions(args.workflow); 975 | 976 | // n8n Management Tools (if API is configured) 977 | case 'n8n_create_workflow': 978 | this.validateToolParams(name, args, ['name', 'nodes', 'connections']); 979 | return n8nHandlers.handleCreateWorkflow(args, this.instanceContext); 980 | case 'n8n_get_workflow': 981 | this.validateToolParams(name, args, ['id']); 982 | return n8nHandlers.handleGetWorkflow(args, this.instanceContext); 983 | case 'n8n_get_workflow_details': 984 | this.validateToolParams(name, args, ['id']); 985 | return n8nHandlers.handleGetWorkflowDetails(args, this.instanceContext); 986 | case 'n8n_get_workflow_structure': 987 | this.validateToolParams(name, args, ['id']); 988 | return n8nHandlers.handleGetWorkflowStructure(args, this.instanceContext); 989 | case 'n8n_get_workflow_minimal': 990 | this.validateToolParams(name, args, ['id']); 991 | return n8nHandlers.handleGetWorkflowMinimal(args, this.instanceContext); 992 | case 'n8n_update_full_workflow': 993 | this.validateToolParams(name, args, ['id']); 994 | return n8nHandlers.handleUpdateWorkflow(args, this.instanceContext); 995 | case 'n8n_update_partial_workflow': 996 | this.validateToolParams(name, args, ['id', 'operations']); 997 | return handleUpdatePartialWorkflow(args, this.instanceContext); 998 | case 'n8n_delete_workflow': 999 | this.validateToolParams(name, args, ['id']); 1000 | return n8nHandlers.handleDeleteWorkflow(args, this.instanceContext); 1001 | case 'n8n_list_workflows': 1002 | // No required parameters 1003 | return n8nHandlers.handleListWorkflows(args, this.instanceContext); 1004 | case 'n8n_validate_workflow': 1005 | this.validateToolParams(name, args, ['id']); 1006 | await this.ensureInitialized(); 1007 | if (!this.repository) throw new Error('Repository not initialized'); 1008 | return n8nHandlers.handleValidateWorkflow(args, this.repository, this.instanceContext); 1009 | case 'n8n_autofix_workflow': 1010 | this.validateToolParams(name, args, ['id']); 1011 | await this.ensureInitialized(); 1012 | if (!this.repository) throw new Error('Repository not initialized'); 1013 | return n8nHandlers.handleAutofixWorkflow(args, this.repository, this.instanceContext); 1014 | case 'n8n_trigger_webhook_workflow': 1015 | this.validateToolParams(name, args, ['webhookUrl']); 1016 | return n8nHandlers.handleTriggerWebhookWorkflow(args, this.instanceContext); 1017 | case 'n8n_get_execution': 1018 | this.validateToolParams(name, args, ['id']); 1019 | return n8nHandlers.handleGetExecution(args, this.instanceContext); 1020 | case 'n8n_list_executions': 1021 | // No required parameters 1022 | return n8nHandlers.handleListExecutions(args, this.instanceContext); 1023 | case 'n8n_delete_execution': 1024 | this.validateToolParams(name, args, ['id']); 1025 | return n8nHandlers.handleDeleteExecution(args, this.instanceContext); 1026 | case 'n8n_health_check': 1027 | // No required parameters 1028 | return n8nHandlers.handleHealthCheck(this.instanceContext); 1029 | case 'n8n_list_available_tools': 1030 | // No required parameters 1031 | return n8nHandlers.handleListAvailableTools(this.instanceContext); 1032 | case 'n8n_diagnostic': 1033 | // No required parameters 1034 | return n8nHandlers.handleDiagnostic({ params: { arguments: args } }, this.instanceContext); 1035 | 1036 | default: 1037 | throw new Error(`Unknown tool: ${name}`); 1038 | } 1039 | } 1040 | 1041 | private async listNodes(filters: any = {}): Promise<any> { 1042 | await this.ensureInitialized(); 1043 | 1044 | let query = 'SELECT * FROM nodes WHERE 1=1'; 1045 | const params: any[] = []; 1046 | 1047 | // console.log('DEBUG list_nodes:', { filters, query, params }); // Removed to prevent stdout interference 1048 | 1049 | if (filters.package) { 1050 | // Handle both formats 1051 | const packageVariants = [ 1052 | filters.package, 1053 | `@n8n/${filters.package}`, 1054 | filters.package.replace('@n8n/', '') 1055 | ]; 1056 | query += ' AND package_name IN (' + packageVariants.map(() => '?').join(',') + ')'; 1057 | params.push(...packageVariants); 1058 | } 1059 | 1060 | if (filters.category) { 1061 | query += ' AND category = ?'; 1062 | params.push(filters.category); 1063 | } 1064 | 1065 | if (filters.developmentStyle) { 1066 | query += ' AND development_style = ?'; 1067 | params.push(filters.developmentStyle); 1068 | } 1069 | 1070 | if (filters.isAITool !== undefined) { 1071 | query += ' AND is_ai_tool = ?'; 1072 | params.push(filters.isAITool ? 1 : 0); 1073 | } 1074 | 1075 | query += ' ORDER BY display_name'; 1076 | 1077 | if (filters.limit) { 1078 | query += ' LIMIT ?'; 1079 | params.push(filters.limit); 1080 | } 1081 | 1082 | const nodes = this.db!.prepare(query).all(...params) as NodeRow[]; 1083 | 1084 | return { 1085 | nodes: nodes.map(node => ({ 1086 | nodeType: node.node_type, 1087 | displayName: node.display_name, 1088 | description: node.description, 1089 | category: node.category, 1090 | package: node.package_name, 1091 | developmentStyle: node.development_style, 1092 | isAITool: Number(node.is_ai_tool) === 1, 1093 | isTrigger: Number(node.is_trigger) === 1, 1094 | isVersioned: Number(node.is_versioned) === 1, 1095 | })), 1096 | totalCount: nodes.length, 1097 | }; 1098 | } 1099 | 1100 | private async getNodeInfo(nodeType: string): Promise<any> { 1101 | await this.ensureInitialized(); 1102 | if (!this.repository) throw new Error('Repository not initialized'); 1103 | 1104 | // First try with normalized type (repository will also normalize internally) 1105 | const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); 1106 | let node = this.repository.getNode(normalizedType); 1107 | 1108 | if (!node && normalizedType !== nodeType) { 1109 | // Try original if normalization changed it 1110 | node = this.repository.getNode(nodeType); 1111 | } 1112 | 1113 | if (!node) { 1114 | // Fallback to other alternatives for edge cases 1115 | const alternatives = getNodeTypeAlternatives(normalizedType); 1116 | 1117 | for (const alt of alternatives) { 1118 | const found = this.repository!.getNode(alt); 1119 | if (found) { 1120 | node = found; 1121 | break; 1122 | } 1123 | } 1124 | } 1125 | 1126 | if (!node) { 1127 | throw new Error(`Node ${nodeType} not found`); 1128 | } 1129 | 1130 | // Add AI tool capabilities information with null safety 1131 | const aiToolCapabilities = { 1132 | canBeUsedAsTool: true, // Any node can be used as a tool in n8n 1133 | hasUsableAsToolProperty: node.isAITool ?? false, 1134 | requiresEnvironmentVariable: !(node.isAITool ?? false) && node.package !== 'n8n-nodes-base', 1135 | toolConnectionType: 'ai_tool', 1136 | commonToolUseCases: this.getCommonAIToolUseCases(node.nodeType), 1137 | environmentRequirement: node.package && node.package !== 'n8n-nodes-base' ? 1138 | 'N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true' : 1139 | null 1140 | }; 1141 | 1142 | // Process outputs to provide clear mapping with null safety 1143 | let outputs = undefined; 1144 | if (node.outputNames && Array.isArray(node.outputNames) && node.outputNames.length > 0) { 1145 | outputs = node.outputNames.map((name: string, index: number) => { 1146 | // Special handling for loop nodes like SplitInBatches 1147 | const descriptions = this.getOutputDescriptions(node.nodeType, name, index); 1148 | return { 1149 | index, 1150 | name, 1151 | description: descriptions?.description ?? '', 1152 | connectionGuidance: descriptions?.connectionGuidance ?? '' 1153 | }; 1154 | }); 1155 | } 1156 | 1157 | return { 1158 | ...node, 1159 | workflowNodeType: getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType), 1160 | aiToolCapabilities, 1161 | outputs 1162 | }; 1163 | } 1164 | 1165 | /** 1166 | * Primary search method used by ALL MCP search tools. 1167 | * 1168 | * This method automatically detects and uses FTS5 full-text search when available 1169 | * (lines 1189-1203), falling back to LIKE queries only if FTS5 table doesn't exist. 1170 | * 1171 | * NOTE: This is separate from NodeRepository.searchNodes() which is legacy LIKE-based. 1172 | * All MCP tool invocations route through this method to leverage FTS5 performance. 1173 | */ 1174 | private async searchNodes( 1175 | query: string, 1176 | limit: number = 20, 1177 | options?: { 1178 | mode?: 'OR' | 'AND' | 'FUZZY'; 1179 | includeSource?: boolean; 1180 | includeExamples?: boolean; 1181 | } 1182 | ): Promise<any> { 1183 | await this.ensureInitialized(); 1184 | if (!this.db) throw new Error('Database not initialized'); 1185 | 1186 | // Normalize the query if it looks like a full node type 1187 | let normalizedQuery = query; 1188 | 1189 | // Check if query contains node type patterns and normalize them 1190 | if (query.includes('n8n-nodes-base.') || query.includes('@n8n/n8n-nodes-langchain.')) { 1191 | normalizedQuery = query 1192 | .replace(/n8n-nodes-base\./g, 'nodes-base.') 1193 | .replace(/@n8n\/n8n-nodes-langchain\./g, 'nodes-langchain.'); 1194 | } 1195 | 1196 | const searchMode = options?.mode || 'OR'; 1197 | 1198 | // Check if FTS5 table exists 1199 | const ftsExists = this.db.prepare(` 1200 | SELECT name FROM sqlite_master 1201 | WHERE type='table' AND name='nodes_fts' 1202 | `).get(); 1203 | 1204 | if (ftsExists) { 1205 | // Use FTS5 search with normalized query 1206 | logger.debug(`Using FTS5 search with includeExamples=${options?.includeExamples}`); 1207 | return this.searchNodesFTS(normalizedQuery, limit, searchMode, options); 1208 | } else { 1209 | // Fallback to LIKE search with normalized query 1210 | logger.debug('Using LIKE search (no FTS5)'); 1211 | return this.searchNodesLIKE(normalizedQuery, limit, options); 1212 | } 1213 | } 1214 | 1215 | private async searchNodesFTS( 1216 | query: string, 1217 | limit: number, 1218 | mode: 'OR' | 'AND' | 'FUZZY', 1219 | options?: { includeSource?: boolean; includeExamples?: boolean; } 1220 | ): Promise<any> { 1221 | if (!this.db) throw new Error('Database not initialized'); 1222 | 1223 | // Clean and prepare the query 1224 | const cleanedQuery = query.trim(); 1225 | if (!cleanedQuery) { 1226 | return { query, results: [], totalCount: 0 }; 1227 | } 1228 | 1229 | // For FUZZY mode, use LIKE search with typo patterns 1230 | if (mode === 'FUZZY') { 1231 | return this.searchNodesFuzzy(cleanedQuery, limit); 1232 | } 1233 | 1234 | let ftsQuery: string; 1235 | 1236 | // Handle exact phrase searches with quotes 1237 | if (cleanedQuery.startsWith('"') && cleanedQuery.endsWith('"')) { 1238 | // Keep exact phrase as is for FTS5 1239 | ftsQuery = cleanedQuery; 1240 | } else { 1241 | // Split into words and handle based on mode 1242 | const words = cleanedQuery.split(/\s+/).filter(w => w.length > 0); 1243 | 1244 | switch (mode) { 1245 | case 'AND': 1246 | // All words must be present 1247 | ftsQuery = words.join(' AND '); 1248 | break; 1249 | 1250 | case 'OR': 1251 | default: 1252 | // Any word can match (default) 1253 | ftsQuery = words.join(' OR '); 1254 | break; 1255 | } 1256 | } 1257 | 1258 | try { 1259 | // Use FTS5 with ranking 1260 | const nodes = this.db.prepare(` 1261 | SELECT 1262 | n.*, 1263 | rank 1264 | FROM nodes n 1265 | JOIN nodes_fts ON n.rowid = nodes_fts.rowid 1266 | WHERE nodes_fts MATCH ? 1267 | ORDER BY 1268 | rank, 1269 | CASE 1270 | WHEN n.display_name = ? THEN 0 1271 | WHEN n.display_name LIKE ? THEN 1 1272 | WHEN n.node_type LIKE ? THEN 2 1273 | ELSE 3 1274 | END, 1275 | n.display_name 1276 | LIMIT ? 1277 | `).all(ftsQuery, cleanedQuery, `%${cleanedQuery}%`, `%${cleanedQuery}%`, limit) as (NodeRow & { rank: number })[]; 1278 | 1279 | // Apply additional relevance scoring for better results 1280 | const scoredNodes = nodes.map(node => { 1281 | const relevanceScore = this.calculateRelevanceScore(node, cleanedQuery); 1282 | return { ...node, relevanceScore }; 1283 | }); 1284 | 1285 | // Sort by combined score (FTS rank + relevance score) 1286 | scoredNodes.sort((a, b) => { 1287 | // Prioritize exact matches 1288 | if (a.display_name.toLowerCase() === cleanedQuery.toLowerCase()) return -1; 1289 | if (b.display_name.toLowerCase() === cleanedQuery.toLowerCase()) return 1; 1290 | 1291 | // Then by relevance score 1292 | if (a.relevanceScore !== b.relevanceScore) { 1293 | return b.relevanceScore - a.relevanceScore; 1294 | } 1295 | 1296 | // Then by FTS rank 1297 | return a.rank - b.rank; 1298 | }); 1299 | 1300 | // If FTS didn't find key primary nodes, augment with LIKE search 1301 | const hasHttpRequest = scoredNodes.some(n => n.node_type === 'nodes-base.httpRequest'); 1302 | if (cleanedQuery.toLowerCase().includes('http') && !hasHttpRequest) { 1303 | // FTS missed HTTP Request, fall back to LIKE search 1304 | logger.debug('FTS missed HTTP Request node, augmenting with LIKE search'); 1305 | return this.searchNodesLIKE(query, limit); 1306 | } 1307 | 1308 | const result: any = { 1309 | query, 1310 | results: scoredNodes.map(node => ({ 1311 | nodeType: node.node_type, 1312 | workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type), 1313 | displayName: node.display_name, 1314 | description: node.description, 1315 | category: node.category, 1316 | package: node.package_name, 1317 | relevance: this.calculateRelevance(node, cleanedQuery) 1318 | })), 1319 | totalCount: scoredNodes.length 1320 | }; 1321 | 1322 | // Only include mode if it's not the default 1323 | if (mode !== 'OR') { 1324 | result.mode = mode; 1325 | } 1326 | 1327 | // Add examples if requested 1328 | if (options && options.includeExamples) { 1329 | try { 1330 | for (const nodeResult of result.results) { 1331 | const examples = this.db!.prepare(` 1332 | SELECT 1333 | parameters_json, 1334 | template_name, 1335 | template_views 1336 | FROM template_node_configs 1337 | WHERE node_type = ? 1338 | ORDER BY rank 1339 | LIMIT 2 1340 | `).all(nodeResult.workflowNodeType) as any[]; 1341 | 1342 | if (examples.length > 0) { 1343 | nodeResult.examples = examples.map((ex: any) => ({ 1344 | configuration: JSON.parse(ex.parameters_json), 1345 | template: ex.template_name, 1346 | views: ex.template_views 1347 | })); 1348 | } 1349 | } 1350 | } catch (error: any) { 1351 | logger.error(`Failed to add examples:`, error); 1352 | } 1353 | } 1354 | 1355 | // Track search query telemetry 1356 | telemetry.trackSearchQuery(query, scoredNodes.length, mode ?? 'OR'); 1357 | 1358 | return result; 1359 | 1360 | } catch (error: any) { 1361 | // If FTS5 query fails, fallback to LIKE search 1362 | logger.warn('FTS5 search failed, falling back to LIKE search:', error.message); 1363 | 1364 | // Special handling for syntax errors 1365 | if (error.message.includes('syntax error') || error.message.includes('fts5')) { 1366 | logger.warn(`FTS5 syntax error for query "${query}" in mode ${mode}`); 1367 | 1368 | // For problematic queries, use LIKE search with mode info 1369 | const likeResult = await this.searchNodesLIKE(query, limit); 1370 | 1371 | // Track search query telemetry for fallback 1372 | telemetry.trackSearchQuery(query, likeResult.results?.length ?? 0, `${mode}_LIKE_FALLBACK`); 1373 | 1374 | return { 1375 | ...likeResult, 1376 | mode 1377 | }; 1378 | } 1379 | 1380 | return this.searchNodesLIKE(query, limit); 1381 | } 1382 | } 1383 | 1384 | private async searchNodesFuzzy(query: string, limit: number): Promise<any> { 1385 | if (!this.db) throw new Error('Database not initialized'); 1386 | 1387 | // Split into words for fuzzy matching 1388 | const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0); 1389 | 1390 | if (words.length === 0) { 1391 | return { query, results: [], totalCount: 0, mode: 'FUZZY' }; 1392 | } 1393 | 1394 | // For fuzzy search, get ALL nodes to ensure we don't miss potential matches 1395 | // We'll limit results after scoring 1396 | const candidateNodes = this.db!.prepare(` 1397 | SELECT * FROM nodes 1398 | `).all() as NodeRow[]; 1399 | 1400 | // Calculate fuzzy scores for candidate nodes 1401 | const scoredNodes = candidateNodes.map(node => { 1402 | const score = this.calculateFuzzyScore(node, query); 1403 | return { node, score }; 1404 | }); 1405 | 1406 | // Filter and sort by score 1407 | const matchingNodes = scoredNodes 1408 | .filter(item => item.score >= 200) // Lower threshold for better typo tolerance 1409 | .sort((a, b) => b.score - a.score) 1410 | .slice(0, limit) 1411 | .map(item => item.node); 1412 | 1413 | // Debug logging 1414 | if (matchingNodes.length === 0) { 1415 | const topScores = scoredNodes 1416 | .sort((a, b) => b.score - a.score) 1417 | .slice(0, 5); 1418 | logger.debug(`FUZZY search for "${query}" - no matches above 400. Top scores:`, 1419 | topScores.map(s => ({ name: s.node.display_name, score: s.score }))); 1420 | } 1421 | 1422 | return { 1423 | query, 1424 | mode: 'FUZZY', 1425 | results: matchingNodes.map(node => ({ 1426 | nodeType: node.node_type, 1427 | workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type), 1428 | displayName: node.display_name, 1429 | description: node.description, 1430 | category: node.category, 1431 | package: node.package_name 1432 | })), 1433 | totalCount: matchingNodes.length 1434 | }; 1435 | } 1436 | 1437 | private calculateFuzzyScore(node: NodeRow, query: string): number { 1438 | const queryLower = query.toLowerCase(); 1439 | const displayNameLower = node.display_name.toLowerCase(); 1440 | const nodeTypeLower = node.node_type.toLowerCase(); 1441 | const nodeTypeClean = nodeTypeLower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, ''); 1442 | 1443 | // Exact match gets highest score 1444 | if (displayNameLower === queryLower || nodeTypeClean === queryLower) { 1445 | return 1000; 1446 | } 1447 | 1448 | // Calculate edit distances for different parts 1449 | const nameDistance = this.getEditDistance(queryLower, displayNameLower); 1450 | const typeDistance = this.getEditDistance(queryLower, nodeTypeClean); 1451 | 1452 | // Also check individual words in the display name 1453 | const nameWords = displayNameLower.split(/\s+/); 1454 | let minWordDistance = Infinity; 1455 | for (const word of nameWords) { 1456 | const distance = this.getEditDistance(queryLower, word); 1457 | if (distance < minWordDistance) { 1458 | minWordDistance = distance; 1459 | } 1460 | } 1461 | 1462 | // Calculate best match score 1463 | const bestDistance = Math.min(nameDistance, typeDistance, minWordDistance); 1464 | 1465 | // Use the length of the matched word for similarity calculation 1466 | let matchedLen = queryLower.length; 1467 | if (minWordDistance === bestDistance) { 1468 | // Find which word matched best 1469 | for (const word of nameWords) { 1470 | if (this.getEditDistance(queryLower, word) === minWordDistance) { 1471 | matchedLen = Math.max(queryLower.length, word.length); 1472 | break; 1473 | } 1474 | } 1475 | } else if (typeDistance === bestDistance) { 1476 | matchedLen = Math.max(queryLower.length, nodeTypeClean.length); 1477 | } else { 1478 | matchedLen = Math.max(queryLower.length, displayNameLower.length); 1479 | } 1480 | 1481 | const similarity = 1 - (bestDistance / matchedLen); 1482 | 1483 | // Boost if query is a substring 1484 | if (displayNameLower.includes(queryLower) || nodeTypeClean.includes(queryLower)) { 1485 | return 800 + (similarity * 100); 1486 | } 1487 | 1488 | // Check if it's a prefix match 1489 | if (displayNameLower.startsWith(queryLower) || 1490 | nodeTypeClean.startsWith(queryLower) || 1491 | nameWords.some(w => w.startsWith(queryLower))) { 1492 | return 700 + (similarity * 100); 1493 | } 1494 | 1495 | // Allow up to 1-2 character differences for typos 1496 | if (bestDistance <= 2) { 1497 | return 500 + ((2 - bestDistance) * 100) + (similarity * 50); 1498 | } 1499 | 1500 | // Allow up to 3 character differences for longer words 1501 | if (bestDistance <= 3 && queryLower.length >= 4) { 1502 | return 400 + ((3 - bestDistance) * 50) + (similarity * 50); 1503 | } 1504 | 1505 | // Base score on similarity 1506 | return similarity * 300; 1507 | } 1508 | 1509 | private getEditDistance(s1: string, s2: string): number { 1510 | // Simple Levenshtein distance implementation 1511 | const m = s1.length; 1512 | const n = s2.length; 1513 | const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); 1514 | 1515 | for (let i = 0; i <= m; i++) dp[i][0] = i; 1516 | for (let j = 0; j <= n; j++) dp[0][j] = j; 1517 | 1518 | for (let i = 1; i <= m; i++) { 1519 | for (let j = 1; j <= n; j++) { 1520 | if (s1[i - 1] === s2[j - 1]) { 1521 | dp[i][j] = dp[i - 1][j - 1]; 1522 | } else { 1523 | dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); 1524 | } 1525 | } 1526 | } 1527 | 1528 | return dp[m][n]; 1529 | } 1530 | 1531 | private async searchNodesLIKE( 1532 | query: string, 1533 | limit: number, 1534 | options?: { includeSource?: boolean; includeExamples?: boolean; } 1535 | ): Promise<any> { 1536 | if (!this.db) throw new Error('Database not initialized'); 1537 | 1538 | // This is the existing LIKE-based implementation 1539 | // Handle exact phrase searches with quotes 1540 | if (query.startsWith('"') && query.endsWith('"')) { 1541 | const exactPhrase = query.slice(1, -1); 1542 | const nodes = this.db!.prepare(` 1543 | SELECT * FROM nodes 1544 | WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ? 1545 | LIMIT ? 1546 | `).all(`%${exactPhrase}%`, `%${exactPhrase}%`, `%${exactPhrase}%`, limit * 3) as NodeRow[]; 1547 | 1548 | // Apply relevance ranking for exact phrase search 1549 | const rankedNodes = this.rankSearchResults(nodes, exactPhrase, limit); 1550 | 1551 | const result: any = { 1552 | query, 1553 | results: rankedNodes.map(node => ({ 1554 | nodeType: node.node_type, 1555 | workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type), 1556 | displayName: node.display_name, 1557 | description: node.description, 1558 | category: node.category, 1559 | package: node.package_name 1560 | })), 1561 | totalCount: rankedNodes.length 1562 | }; 1563 | 1564 | // Add examples if requested 1565 | if (options?.includeExamples) { 1566 | for (const nodeResult of result.results) { 1567 | try { 1568 | const examples = this.db!.prepare(` 1569 | SELECT 1570 | parameters_json, 1571 | template_name, 1572 | template_views 1573 | FROM template_node_configs 1574 | WHERE node_type = ? 1575 | ORDER BY rank 1576 | LIMIT 2 1577 | `).all(nodeResult.workflowNodeType) as any[]; 1578 | 1579 | if (examples.length > 0) { 1580 | nodeResult.examples = examples.map((ex: any) => ({ 1581 | configuration: JSON.parse(ex.parameters_json), 1582 | template: ex.template_name, 1583 | views: ex.template_views 1584 | })); 1585 | } 1586 | } catch (error: any) { 1587 | logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message); 1588 | } 1589 | } 1590 | } 1591 | 1592 | return result; 1593 | } 1594 | 1595 | // Split into words for normal search 1596 | const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0); 1597 | 1598 | if (words.length === 0) { 1599 | return { query, results: [], totalCount: 0 }; 1600 | } 1601 | 1602 | // Build conditions for each word 1603 | const conditions = words.map(() => 1604 | '(node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)' 1605 | ).join(' OR '); 1606 | 1607 | const params: any[] = words.flatMap(w => [`%${w}%`, `%${w}%`, `%${w}%`]); 1608 | // Fetch more results initially to ensure we get the best matches after ranking 1609 | params.push(limit * 3); 1610 | 1611 | const nodes = this.db!.prepare(` 1612 | SELECT DISTINCT * FROM nodes 1613 | WHERE ${conditions} 1614 | LIMIT ? 1615 | `).all(...params) as NodeRow[]; 1616 | 1617 | // Apply relevance ranking 1618 | const rankedNodes = this.rankSearchResults(nodes, query, limit); 1619 | 1620 | const result: any = { 1621 | query, 1622 | results: rankedNodes.map(node => ({ 1623 | nodeType: node.node_type, 1624 | workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type), 1625 | displayName: node.display_name, 1626 | description: node.description, 1627 | category: node.category, 1628 | package: node.package_name 1629 | })), 1630 | totalCount: rankedNodes.length 1631 | }; 1632 | 1633 | // Add examples if requested 1634 | if (options?.includeExamples) { 1635 | for (const nodeResult of result.results) { 1636 | try { 1637 | const examples = this.db!.prepare(` 1638 | SELECT 1639 | parameters_json, 1640 | template_name, 1641 | template_views 1642 | FROM template_node_configs 1643 | WHERE node_type = ? 1644 | ORDER BY rank 1645 | LIMIT 2 1646 | `).all(nodeResult.workflowNodeType) as any[]; 1647 | 1648 | if (examples.length > 0) { 1649 | nodeResult.examples = examples.map((ex: any) => ({ 1650 | configuration: JSON.parse(ex.parameters_json), 1651 | template: ex.template_name, 1652 | views: ex.template_views 1653 | })); 1654 | } 1655 | } catch (error: any) { 1656 | logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message); 1657 | } 1658 | } 1659 | } 1660 | 1661 | return result; 1662 | } 1663 | 1664 | private calculateRelevance(node: NodeRow, query: string): string { 1665 | const lowerQuery = query.toLowerCase(); 1666 | if (node.node_type.toLowerCase().includes(lowerQuery)) return 'high'; 1667 | if (node.display_name.toLowerCase().includes(lowerQuery)) return 'high'; 1668 | if (node.description?.toLowerCase().includes(lowerQuery)) return 'medium'; 1669 | return 'low'; 1670 | } 1671 | 1672 | private calculateRelevanceScore(node: NodeRow, query: string): number { 1673 | const query_lower = query.toLowerCase(); 1674 | const name_lower = node.display_name.toLowerCase(); 1675 | const type_lower = node.node_type.toLowerCase(); 1676 | const type_without_prefix = type_lower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, ''); 1677 | 1678 | let score = 0; 1679 | 1680 | // Exact match in display name (highest priority) 1681 | if (name_lower === query_lower) { 1682 | score = 1000; 1683 | } 1684 | // Exact match in node type (without prefix) 1685 | else if (type_without_prefix === query_lower) { 1686 | score = 950; 1687 | } 1688 | // Special boost for common primary nodes 1689 | else if (query_lower === 'webhook' && node.node_type === 'nodes-base.webhook') { 1690 | score = 900; 1691 | } 1692 | else if ((query_lower === 'http' || query_lower === 'http request' || query_lower === 'http call') && node.node_type === 'nodes-base.httpRequest') { 1693 | score = 900; 1694 | } 1695 | // Additional boost for multi-word queries matching primary nodes 1696 | else if (query_lower.includes('http') && query_lower.includes('call') && node.node_type === 'nodes-base.httpRequest') { 1697 | score = 890; 1698 | } 1699 | else if (query_lower.includes('http') && node.node_type === 'nodes-base.httpRequest') { 1700 | score = 850; 1701 | } 1702 | // Boost for webhook queries 1703 | else if (query_lower.includes('webhook') && node.node_type === 'nodes-base.webhook') { 1704 | score = 850; 1705 | } 1706 | // Display name starts with query 1707 | else if (name_lower.startsWith(query_lower)) { 1708 | score = 800; 1709 | } 1710 | // Word boundary match in display name 1711 | else if (new RegExp(`\\b${query_lower}\\b`, 'i').test(node.display_name)) { 1712 | score = 700; 1713 | } 1714 | // Contains in display name 1715 | else if (name_lower.includes(query_lower)) { 1716 | score = 600; 1717 | } 1718 | // Type contains query (without prefix) 1719 | else if (type_without_prefix.includes(query_lower)) { 1720 | score = 500; 1721 | } 1722 | // Contains in description 1723 | else if (node.description?.toLowerCase().includes(query_lower)) { 1724 | score = 400; 1725 | } 1726 | 1727 | return score; 1728 | } 1729 | 1730 | private rankSearchResults(nodes: NodeRow[], query: string, limit: number): NodeRow[] { 1731 | const query_lower = query.toLowerCase(); 1732 | 1733 | // Calculate relevance scores for each node 1734 | const scoredNodes = nodes.map(node => { 1735 | const name_lower = node.display_name.toLowerCase(); 1736 | const type_lower = node.node_type.toLowerCase(); 1737 | const type_without_prefix = type_lower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, ''); 1738 | 1739 | let score = 0; 1740 | 1741 | // Exact match in display name (highest priority) 1742 | if (name_lower === query_lower) { 1743 | score = 1000; 1744 | } 1745 | // Exact match in node type (without prefix) 1746 | else if (type_without_prefix === query_lower) { 1747 | score = 950; 1748 | } 1749 | // Special boost for common primary nodes 1750 | else if (query_lower === 'webhook' && node.node_type === 'nodes-base.webhook') { 1751 | score = 900; 1752 | } 1753 | else if ((query_lower === 'http' || query_lower === 'http request' || query_lower === 'http call') && node.node_type === 'nodes-base.httpRequest') { 1754 | score = 900; 1755 | } 1756 | // Boost for webhook queries 1757 | else if (query_lower.includes('webhook') && node.node_type === 'nodes-base.webhook') { 1758 | score = 850; 1759 | } 1760 | // Additional boost for http queries 1761 | else if (query_lower.includes('http') && node.node_type === 'nodes-base.httpRequest') { 1762 | score = 850; 1763 | } 1764 | // Display name starts with query 1765 | else if (name_lower.startsWith(query_lower)) { 1766 | score = 800; 1767 | } 1768 | // Word boundary match in display name 1769 | else if (new RegExp(`\\b${query_lower}\\b`, 'i').test(node.display_name)) { 1770 | score = 700; 1771 | } 1772 | // Contains in display name 1773 | else if (name_lower.includes(query_lower)) { 1774 | score = 600; 1775 | } 1776 | // Type contains query (without prefix) 1777 | else if (type_without_prefix.includes(query_lower)) { 1778 | score = 500; 1779 | } 1780 | // Contains in description 1781 | else if (node.description?.toLowerCase().includes(query_lower)) { 1782 | score = 400; 1783 | } 1784 | 1785 | // For multi-word queries, check if all words are present 1786 | const words = query_lower.split(/\s+/).filter(w => w.length > 0); 1787 | if (words.length > 1) { 1788 | const allWordsInName = words.every(word => name_lower.includes(word)); 1789 | const allWordsInDesc = words.every(word => node.description?.toLowerCase().includes(word)); 1790 | 1791 | if (allWordsInName) score += 200; 1792 | else if (allWordsInDesc) score += 100; 1793 | 1794 | // Special handling for common multi-word queries 1795 | if (query_lower === 'http call' && name_lower === 'http request') { 1796 | score = 920; // Boost HTTP Request for "http call" query 1797 | } 1798 | } 1799 | 1800 | return { node, score }; 1801 | }); 1802 | 1803 | // Sort by score (descending) and then by display name (ascending) 1804 | scoredNodes.sort((a, b) => { 1805 | if (a.score !== b.score) { 1806 | return b.score - a.score; 1807 | } 1808 | return a.node.display_name.localeCompare(b.node.display_name); 1809 | }); 1810 | 1811 | // Return only the requested number of results 1812 | return scoredNodes.slice(0, limit).map(item => item.node); 1813 | } 1814 | 1815 | private async listAITools(): Promise<any> { 1816 | await this.ensureInitialized(); 1817 | if (!this.repository) throw new Error('Repository not initialized'); 1818 | const tools = this.repository.getAITools(); 1819 | 1820 | // Debug: Check if is_ai_tool column is populated 1821 | const aiCount = this.db!.prepare('SELECT COUNT(*) as ai_count FROM nodes WHERE is_ai_tool = 1').get() as any; 1822 | // console.log('DEBUG list_ai_tools:', { 1823 | // toolsLength: tools.length, 1824 | // aiCountInDB: aiCount.ai_count, 1825 | // sampleTools: tools.slice(0, 3) 1826 | // }); // Removed to prevent stdout interference 1827 | 1828 | return { 1829 | tools, 1830 | totalCount: tools.length, 1831 | requirements: { 1832 | environmentVariable: 'N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true', 1833 | nodeProperty: 'usableAsTool: true', 1834 | }, 1835 | usage: { 1836 | description: 'These nodes have the usableAsTool property set to true, making them optimized for AI agent usage.', 1837 | note: 'ANY node in n8n can be used as an AI tool by connecting it to the ai_tool port of an AI Agent node.', 1838 | examples: [ 1839 | 'Regular nodes like Slack, Google Sheets, or HTTP Request can be used as tools', 1840 | 'Connect any node to an AI Agent\'s tool port to make it available for AI-driven automation', 1841 | 'Community nodes require the environment variable to be set' 1842 | ] 1843 | } 1844 | }; 1845 | } 1846 | 1847 | private async getNodeDocumentation(nodeType: string): Promise<any> { 1848 | await this.ensureInitialized(); 1849 | if (!this.db) throw new Error('Database not initialized'); 1850 | 1851 | // First try with normalized type 1852 | const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); 1853 | let node = this.db!.prepare(` 1854 | SELECT node_type, display_name, documentation, description 1855 | FROM nodes 1856 | WHERE node_type = ? 1857 | `).get(normalizedType) as NodeRow | undefined; 1858 | 1859 | // If not found and normalization changed the type, try original 1860 | if (!node && normalizedType !== nodeType) { 1861 | node = this.db!.prepare(` 1862 | SELECT node_type, display_name, documentation, description 1863 | FROM nodes 1864 | WHERE node_type = ? 1865 | `).get(nodeType) as NodeRow | undefined; 1866 | } 1867 | 1868 | // If still not found, try alternatives 1869 | if (!node) { 1870 | const alternatives = getNodeTypeAlternatives(normalizedType); 1871 | 1872 | for (const alt of alternatives) { 1873 | node = this.db!.prepare(` 1874 | SELECT node_type, display_name, documentation, description 1875 | FROM nodes 1876 | WHERE node_type = ? 1877 | `).get(alt) as NodeRow | undefined; 1878 | 1879 | if (node) break; 1880 | } 1881 | } 1882 | 1883 | if (!node) { 1884 | throw new Error(`Node ${nodeType} not found`); 1885 | } 1886 | 1887 | // If no documentation, generate fallback with null safety 1888 | if (!node.documentation) { 1889 | const essentials = await this.getNodeEssentials(nodeType); 1890 | 1891 | return { 1892 | nodeType: node.node_type, 1893 | displayName: node.display_name || 'Unknown Node', 1894 | documentation: ` 1895 | # ${node.display_name || 'Unknown Node'} 1896 | 1897 | ${node.description || 'No description available.'} 1898 | 1899 | ## Common Properties 1900 | 1901 | ${essentials?.commonProperties?.length > 0 ? 1902 | essentials.commonProperties.map((p: any) => 1903 | `### ${p.displayName || 'Property'}\n${p.description || `Type: ${p.type || 'unknown'}`}` 1904 | ).join('\n\n') : 1905 | 'No common properties available.'} 1906 | 1907 | ## Note 1908 | Full documentation is being prepared. For now, use get_node_essentials for configuration help. 1909 | `, 1910 | hasDocumentation: false 1911 | }; 1912 | } 1913 | 1914 | return { 1915 | nodeType: node.node_type, 1916 | displayName: node.display_name || 'Unknown Node', 1917 | documentation: node.documentation, 1918 | hasDocumentation: true, 1919 | }; 1920 | } 1921 | 1922 | private async getDatabaseStatistics(): Promise<any> { 1923 | await this.ensureInitialized(); 1924 | if (!this.db) throw new Error('Database not initialized'); 1925 | const stats = this.db!.prepare(` 1926 | SELECT 1927 | COUNT(*) as total, 1928 | SUM(is_ai_tool) as ai_tools, 1929 | SUM(is_trigger) as triggers, 1930 | SUM(is_versioned) as versioned, 1931 | SUM(CASE WHEN documentation IS NOT NULL THEN 1 ELSE 0 END) as with_docs, 1932 | COUNT(DISTINCT package_name) as packages, 1933 | COUNT(DISTINCT category) as categories 1934 | FROM nodes 1935 | `).get() as any; 1936 | 1937 | const packages = this.db!.prepare(` 1938 | SELECT package_name, COUNT(*) as count 1939 | FROM nodes 1940 | GROUP BY package_name 1941 | `).all() as any[]; 1942 | 1943 | // Get template statistics 1944 | const templateStats = this.db!.prepare(` 1945 | SELECT 1946 | COUNT(*) as total_templates, 1947 | AVG(views) as avg_views, 1948 | MIN(views) as min_views, 1949 | MAX(views) as max_views 1950 | FROM templates 1951 | `).get() as any; 1952 | 1953 | return { 1954 | totalNodes: stats.total, 1955 | totalTemplates: templateStats.total_templates || 0, 1956 | statistics: { 1957 | aiTools: stats.ai_tools, 1958 | triggers: stats.triggers, 1959 | versionedNodes: stats.versioned, 1960 | nodesWithDocumentation: stats.with_docs, 1961 | documentationCoverage: Math.round((stats.with_docs / stats.total) * 100) + '%', 1962 | uniquePackages: stats.packages, 1963 | uniqueCategories: stats.categories, 1964 | templates: { 1965 | total: templateStats.total_templates || 0, 1966 | avgViews: Math.round(templateStats.avg_views || 0), 1967 | minViews: templateStats.min_views || 0, 1968 | maxViews: templateStats.max_views || 0 1969 | } 1970 | }, 1971 | packageBreakdown: packages.map(pkg => ({ 1972 | package: pkg.package_name, 1973 | nodeCount: pkg.count, 1974 | })), 1975 | }; 1976 | } 1977 | 1978 | private async getNodeEssentials(nodeType: string, includeExamples?: boolean): Promise<any> { 1979 | await this.ensureInitialized(); 1980 | if (!this.repository) throw new Error('Repository not initialized'); 1981 | 1982 | // Check cache first (cache key includes includeExamples) 1983 | const cacheKey = `essentials:${nodeType}:${includeExamples ? 'withExamples' : 'basic'}`; 1984 | const cached = this.cache.get(cacheKey); 1985 | if (cached) return cached; 1986 | 1987 | // Get the full node information 1988 | // First try with normalized type 1989 | const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); 1990 | let node = this.repository.getNode(normalizedType); 1991 | 1992 | if (!node && normalizedType !== nodeType) { 1993 | // Try original if normalization changed it 1994 | node = this.repository.getNode(nodeType); 1995 | } 1996 | 1997 | if (!node) { 1998 | // Fallback to other alternatives for edge cases 1999 | const alternatives = getNodeTypeAlternatives(normalizedType); 2000 | 2001 | for (const alt of alternatives) { 2002 | const found = this.repository!.getNode(alt); 2003 | if (found) { 2004 | node = found; 2005 | break; 2006 | } 2007 | } 2008 | } 2009 | 2010 | if (!node) { 2011 | throw new Error(`Node ${nodeType} not found`); 2012 | } 2013 | 2014 | // Get properties (already parsed by repository) 2015 | const allProperties = node.properties || []; 2016 | 2017 | // Get essential properties 2018 | const essentials = PropertyFilter.getEssentials(allProperties, node.nodeType); 2019 | 2020 | // Get operations (already parsed by repository) 2021 | const operations = node.operations || []; 2022 | 2023 | const result = { 2024 | nodeType: node.nodeType, 2025 | workflowNodeType: getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType), 2026 | displayName: node.displayName, 2027 | description: node.description, 2028 | category: node.category, 2029 | version: node.version ?? '1', 2030 | isVersioned: node.isVersioned ?? false, 2031 | requiredProperties: essentials.required, 2032 | commonProperties: essentials.common, 2033 | operations: operations.map((op: any) => ({ 2034 | name: op.name || op.operation, 2035 | description: op.description, 2036 | action: op.action, 2037 | resource: op.resource 2038 | })), 2039 | // Examples removed - use validate_node_operation for working configurations 2040 | metadata: { 2041 | totalProperties: allProperties.length, 2042 | isAITool: node.isAITool ?? false, 2043 | isTrigger: node.isTrigger ?? false, 2044 | isWebhook: node.isWebhook ?? false, 2045 | hasCredentials: node.credentials ? true : false, 2046 | package: node.package ?? 'n8n-nodes-base', 2047 | developmentStyle: node.developmentStyle ?? 'programmatic' 2048 | } 2049 | }; 2050 | 2051 | // Add examples from templates if requested 2052 | if (includeExamples) { 2053 | try { 2054 | // Use the already-computed workflowNodeType from result (line 1888) 2055 | // This ensures consistency with search_nodes behavior (line 1203) 2056 | const examples = this.db!.prepare(` 2057 | SELECT 2058 | parameters_json, 2059 | template_name, 2060 | template_views, 2061 | complexity, 2062 | use_cases, 2063 | has_credentials, 2064 | has_expressions 2065 | FROM template_node_configs 2066 | WHERE node_type = ? 2067 | ORDER BY rank 2068 | LIMIT 3 2069 | `).all(result.workflowNodeType) as any[]; 2070 | 2071 | if (examples.length > 0) { 2072 | (result as any).examples = examples.map((ex: any) => ({ 2073 | configuration: JSON.parse(ex.parameters_json), 2074 | source: { 2075 | template: ex.template_name, 2076 | views: ex.template_views, 2077 | complexity: ex.complexity 2078 | }, 2079 | useCases: ex.use_cases ? JSON.parse(ex.use_cases).slice(0, 2) : [], 2080 | metadata: { 2081 | hasCredentials: ex.has_credentials === 1, 2082 | hasExpressions: ex.has_expressions === 1 2083 | } 2084 | })); 2085 | 2086 | (result as any).examplesCount = examples.length; 2087 | } else { 2088 | (result as any).examples = []; 2089 | (result as any).examplesCount = 0; 2090 | } 2091 | } catch (error: any) { 2092 | logger.warn(`Failed to fetch examples for ${nodeType}:`, error.message); 2093 | (result as any).examples = []; 2094 | (result as any).examplesCount = 0; 2095 | } 2096 | } 2097 | 2098 | // Cache for 1 hour 2099 | this.cache.set(cacheKey, result, 3600); 2100 | 2101 | return result; 2102 | } 2103 | 2104 | private async searchNodeProperties(nodeType: string, query: string, maxResults: number = 20): Promise<any> { 2105 | await this.ensureInitialized(); 2106 | if (!this.repository) throw new Error('Repository not initialized'); 2107 | 2108 | // Get the node 2109 | // First try with normalized type 2110 | const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); 2111 | let node = this.repository.getNode(normalizedType); 2112 | 2113 | if (!node && normalizedType !== nodeType) { 2114 | // Try original if normalization changed it 2115 | node = this.repository.getNode(nodeType); 2116 | } 2117 | 2118 | if (!node) { 2119 | // Fallback to other alternatives for edge cases 2120 | const alternatives = getNodeTypeAlternatives(normalizedType); 2121 | 2122 | for (const alt of alternatives) { 2123 | const found = this.repository!.getNode(alt); 2124 | if (found) { 2125 | node = found; 2126 | break; 2127 | } 2128 | } 2129 | } 2130 | 2131 | if (!node) { 2132 | throw new Error(`Node ${nodeType} not found`); 2133 | } 2134 | 2135 | // Get properties and search (already parsed by repository) 2136 | const allProperties = node.properties || []; 2137 | const matches = PropertyFilter.searchProperties(allProperties, query, maxResults); 2138 | 2139 | return { 2140 | nodeType: node.nodeType, 2141 | query, 2142 | matches: matches.map((match: any) => ({ 2143 | name: match.name, 2144 | displayName: match.displayName, 2145 | type: match.type, 2146 | description: match.description, 2147 | path: match.path || match.name, 2148 | required: match.required, 2149 | default: match.default, 2150 | options: match.options, 2151 | showWhen: match.showWhen 2152 | })), 2153 | totalMatches: matches.length, 2154 | searchedIn: allProperties.length + ' properties' 2155 | }; 2156 | } 2157 | 2158 | private getPropertyValue(config: any, path: string): any { 2159 | const parts = path.split('.'); 2160 | let value = config; 2161 | 2162 | for (const part of parts) { 2163 | // Handle array notation like parameters[0] 2164 | const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/); 2165 | if (arrayMatch) { 2166 | value = value?.[arrayMatch[1]]?.[parseInt(arrayMatch[2])]; 2167 | } else { 2168 | value = value?.[part]; 2169 | } 2170 | } 2171 | 2172 | return value; 2173 | } 2174 | 2175 | private async listTasks(category?: string): Promise<any> { 2176 | if (category) { 2177 | const categories = TaskTemplates.getTaskCategories(); 2178 | const tasks = categories[category]; 2179 | 2180 | if (!tasks) { 2181 | throw new Error( 2182 | `Unknown category: ${category}. Available categories: ${Object.keys(categories).join(', ')}` 2183 | ); 2184 | } 2185 | 2186 | return { 2187 | category, 2188 | tasks: tasks.map(task => { 2189 | const template = TaskTemplates.getTaskTemplate(task); 2190 | return { 2191 | task, 2192 | description: template?.description || '', 2193 | nodeType: template?.nodeType || '' 2194 | }; 2195 | }) 2196 | }; 2197 | } 2198 | 2199 | // Return all tasks grouped by category 2200 | const categories = TaskTemplates.getTaskCategories(); 2201 | const result: any = { 2202 | totalTasks: TaskTemplates.getAllTasks().length, 2203 | categories: {} 2204 | }; 2205 | 2206 | for (const [cat, tasks] of Object.entries(categories)) { 2207 | result.categories[cat] = tasks.map(task => { 2208 | const template = TaskTemplates.getTaskTemplate(task); 2209 | return { 2210 | task, 2211 | description: template?.description || '', 2212 | nodeType: template?.nodeType || '' 2213 | }; 2214 | }); 2215 | } 2216 | 2217 | return result; 2218 | } 2219 | 2220 | private async validateNodeConfig( 2221 | nodeType: string, 2222 | config: Record<string, any>, 2223 | mode: ValidationMode = 'operation', 2224 | profile: ValidationProfile = 'ai-friendly' 2225 | ): Promise<any> { 2226 | await this.ensureInitialized(); 2227 | if (!this.repository) throw new Error('Repository not initialized'); 2228 | 2229 | // Get node info to access properties 2230 | // First try with normalized type 2231 | const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); 2232 | let node = this.repository.getNode(normalizedType); 2233 | 2234 | if (!node && normalizedType !== nodeType) { 2235 | // Try original if normalization changed it 2236 | node = this.repository.getNode(nodeType); 2237 | } 2238 | 2239 | if (!node) { 2240 | // Fallback to other alternatives for edge cases 2241 | const alternatives = getNodeTypeAlternatives(normalizedType); 2242 | 2243 | for (const alt of alternatives) { 2244 | const found = this.repository!.getNode(alt); 2245 | if (found) { 2246 | node = found; 2247 | break; 2248 | } 2249 | } 2250 | } 2251 | 2252 | if (!node) { 2253 | throw new Error(`Node ${nodeType} not found`); 2254 | } 2255 | 2256 | // Get properties 2257 | const properties = node.properties || []; 2258 | 2259 | // Use enhanced validator with operation mode by default 2260 | const validationResult = EnhancedConfigValidator.validateWithMode( 2261 | node.nodeType, 2262 | config, 2263 | properties, 2264 | mode, 2265 | profile 2266 | ); 2267 | 2268 | // Add node context to result 2269 | return { 2270 | nodeType: node.nodeType, 2271 | workflowNodeType: getWorkflowNodeType(node.package, node.nodeType), 2272 | displayName: node.displayName, 2273 | ...validationResult, 2274 | summary: { 2275 | hasErrors: !validationResult.valid, 2276 | errorCount: validationResult.errors.length, 2277 | warningCount: validationResult.warnings.length, 2278 | suggestionCount: validationResult.suggestions.length 2279 | } 2280 | }; 2281 | } 2282 | 2283 | private async getPropertyDependencies(nodeType: string, config?: Record<string, any>): Promise<any> { 2284 | await this.ensureInitialized(); 2285 | if (!this.repository) throw new Error('Repository not initialized'); 2286 | 2287 | // Get node info to access properties 2288 | // First try with normalized type 2289 | const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); 2290 | let node = this.repository.getNode(normalizedType); 2291 | 2292 | if (!node && normalizedType !== nodeType) { 2293 | // Try original if normalization changed it 2294 | node = this.repository.getNode(nodeType); 2295 | } 2296 | 2297 | if (!node) { 2298 | // Fallback to other alternatives for edge cases 2299 | const alternatives = getNodeTypeAlternatives(normalizedType); 2300 | 2301 | for (const alt of alternatives) { 2302 | const found = this.repository!.getNode(alt); 2303 | if (found) { 2304 | node = found; 2305 | break; 2306 | } 2307 | } 2308 | } 2309 | 2310 | if (!node) { 2311 | throw new Error(`Node ${nodeType} not found`); 2312 | } 2313 | 2314 | // Get properties 2315 | const properties = node.properties || []; 2316 | 2317 | // Analyze dependencies 2318 | const analysis = PropertyDependencies.analyze(properties); 2319 | 2320 | // If config provided, check visibility impact 2321 | let visibilityImpact = null; 2322 | if (config) { 2323 | visibilityImpact = PropertyDependencies.getVisibilityImpact(properties, config); 2324 | } 2325 | 2326 | return { 2327 | nodeType: node.nodeType, 2328 | displayName: node.displayName, 2329 | ...analysis, 2330 | currentConfig: config ? { 2331 | providedValues: config, 2332 | visibilityImpact 2333 | } : undefined 2334 | }; 2335 | } 2336 | 2337 | private async getNodeAsToolInfo(nodeType: string): Promise<any> { 2338 | await this.ensureInitialized(); 2339 | if (!this.repository) throw new Error('Repository not initialized'); 2340 | 2341 | // Get node info 2342 | // First try with normalized type 2343 | const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); 2344 | let node = this.repository.getNode(normalizedType); 2345 | 2346 | if (!node && normalizedType !== nodeType) { 2347 | // Try original if normalization changed it 2348 | node = this.repository.getNode(nodeType); 2349 | } 2350 | 2351 | if (!node) { 2352 | // Fallback to other alternatives for edge cases 2353 | const alternatives = getNodeTypeAlternatives(normalizedType); 2354 | 2355 | for (const alt of alternatives) { 2356 | const found = this.repository!.getNode(alt); 2357 | if (found) { 2358 | node = found; 2359 | break; 2360 | } 2361 | } 2362 | } 2363 | 2364 | if (!node) { 2365 | throw new Error(`Node ${nodeType} not found`); 2366 | } 2367 | 2368 | // Determine common AI tool use cases based on node type 2369 | const commonUseCases = this.getCommonAIToolUseCases(node.nodeType); 2370 | 2371 | // Build AI tool capabilities info 2372 | const aiToolCapabilities = { 2373 | canBeUsedAsTool: true, // In n8n, ANY node can be used as a tool when connected to AI Agent 2374 | hasUsableAsToolProperty: node.isAITool, 2375 | requiresEnvironmentVariable: !node.isAITool && node.package !== 'n8n-nodes-base', 2376 | connectionType: 'ai_tool', 2377 | commonUseCases, 2378 | requirements: { 2379 | connection: 'Connect to the "ai_tool" port of an AI Agent node', 2380 | environment: node.package !== 'n8n-nodes-base' ? 2381 | 'Set N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true for community nodes' : 2382 | 'No special environment variables needed for built-in nodes' 2383 | }, 2384 | examples: this.getAIToolExamples(node.nodeType), 2385 | tips: [ 2386 | 'Give the tool a clear, descriptive name in the AI Agent settings', 2387 | 'Write a detailed tool description to help the AI understand when to use it', 2388 | 'Test the node independently before connecting it as a tool', 2389 | node.isAITool ? 2390 | 'This node is optimized for AI tool usage' : 2391 | 'This is a regular node that can be used as an AI tool' 2392 | ] 2393 | }; 2394 | 2395 | return { 2396 | nodeType: node.nodeType, 2397 | workflowNodeType: getWorkflowNodeType(node.package, node.nodeType), 2398 | displayName: node.displayName, 2399 | description: node.description, 2400 | package: node.package, 2401 | isMarkedAsAITool: node.isAITool, 2402 | aiToolCapabilities 2403 | }; 2404 | } 2405 | 2406 | private getOutputDescriptions(nodeType: string, outputName: string, index: number): { description: string, connectionGuidance: string } { 2407 | // Special handling for loop nodes 2408 | if (nodeType === 'nodes-base.splitInBatches') { 2409 | if (outputName === 'done' && index === 0) { 2410 | return { 2411 | description: 'Final processed data after all iterations complete', 2412 | connectionGuidance: 'Connect to nodes that should run AFTER the loop completes' 2413 | }; 2414 | } else if (outputName === 'loop' && index === 1) { 2415 | return { 2416 | description: 'Current batch data for this iteration', 2417 | connectionGuidance: 'Connect to nodes that process items INSIDE the loop (and connect their output back to this node)' 2418 | }; 2419 | } 2420 | } 2421 | 2422 | // Special handling for IF node 2423 | if (nodeType === 'nodes-base.if') { 2424 | if (outputName === 'true' && index === 0) { 2425 | return { 2426 | description: 'Items that match the condition', 2427 | connectionGuidance: 'Connect to nodes that handle the TRUE case' 2428 | }; 2429 | } else if (outputName === 'false' && index === 1) { 2430 | return { 2431 | description: 'Items that do not match the condition', 2432 | connectionGuidance: 'Connect to nodes that handle the FALSE case' 2433 | }; 2434 | } 2435 | } 2436 | 2437 | // Special handling for Switch node 2438 | if (nodeType === 'nodes-base.switch') { 2439 | return { 2440 | description: `Output ${index}: ${outputName || 'Route ' + index}`, 2441 | connectionGuidance: `Connect to nodes for the "${outputName || 'route ' + index}" case` 2442 | }; 2443 | } 2444 | 2445 | // Default handling 2446 | return { 2447 | description: outputName || `Output ${index}`, 2448 | connectionGuidance: `Connect to downstream nodes` 2449 | }; 2450 | } 2451 | 2452 | private getCommonAIToolUseCases(nodeType: string): string[] { 2453 | const useCaseMap: Record<string, string[]> = { 2454 | 'nodes-base.slack': [ 2455 | 'Send notifications about task completion', 2456 | 'Post updates to channels', 2457 | 'Send direct messages', 2458 | 'Create alerts and reminders' 2459 | ], 2460 | 'nodes-base.googleSheets': [ 2461 | 'Read data for analysis', 2462 | 'Log results and outputs', 2463 | 'Update spreadsheet records', 2464 | 'Create reports' 2465 | ], 2466 | 'nodes-base.gmail': [ 2467 | 'Send email notifications', 2468 | 'Read and process emails', 2469 | 'Send reports and summaries', 2470 | 'Handle email-based workflows' 2471 | ], 2472 | 'nodes-base.httpRequest': [ 2473 | 'Call external APIs', 2474 | 'Fetch data from web services', 2475 | 'Send webhooks', 2476 | 'Integrate with any REST API' 2477 | ], 2478 | 'nodes-base.postgres': [ 2479 | 'Query database for information', 2480 | 'Store analysis results', 2481 | 'Update records based on AI decisions', 2482 | 'Generate reports from data' 2483 | ], 2484 | 'nodes-base.webhook': [ 2485 | 'Receive external triggers', 2486 | 'Create callback endpoints', 2487 | 'Handle incoming data', 2488 | 'Integrate with external systems' 2489 | ] 2490 | }; 2491 | 2492 | // Check for partial matches 2493 | for (const [key, useCases] of Object.entries(useCaseMap)) { 2494 | if (nodeType.includes(key)) { 2495 | return useCases; 2496 | } 2497 | } 2498 | 2499 | // Generic use cases for unknown nodes 2500 | return [ 2501 | 'Perform automated actions', 2502 | 'Integrate with external services', 2503 | 'Process and transform data', 2504 | 'Extend AI agent capabilities' 2505 | ]; 2506 | } 2507 | 2508 | private getAIToolExamples(nodeType: string): any { 2509 | const exampleMap: Record<string, any> = { 2510 | 'nodes-base.slack': { 2511 | toolName: 'Send Slack Message', 2512 | toolDescription: 'Sends a message to a specified Slack channel or user. Use this to notify team members about important events or results.', 2513 | nodeConfig: { 2514 | resource: 'message', 2515 | operation: 'post', 2516 | channel: '={{ $fromAI("channel", "The Slack channel to send to, e.g. #general") }}', 2517 | text: '={{ $fromAI("message", "The message content to send") }}' 2518 | } 2519 | }, 2520 | 'nodes-base.googleSheets': { 2521 | toolName: 'Update Google Sheet', 2522 | toolDescription: 'Reads or updates data in a Google Sheets spreadsheet. Use this to log information, retrieve data, or update records.', 2523 | nodeConfig: { 2524 | operation: 'append', 2525 | sheetId: 'your-sheet-id', 2526 | range: 'A:Z', 2527 | dataMode: 'autoMap' 2528 | } 2529 | }, 2530 | 'nodes-base.httpRequest': { 2531 | toolName: 'Call API', 2532 | toolDescription: 'Makes HTTP requests to external APIs. Use this to fetch data, trigger webhooks, or integrate with any web service.', 2533 | nodeConfig: { 2534 | method: '={{ $fromAI("method", "HTTP method: GET, POST, PUT, DELETE") }}', 2535 | url: '={{ $fromAI("url", "The complete API endpoint URL") }}', 2536 | sendBody: true, 2537 | bodyContentType: 'json', 2538 | jsonBody: '={{ $fromAI("body", "Request body as JSON object") }}' 2539 | } 2540 | } 2541 | }; 2542 | 2543 | // Check for exact match or partial match 2544 | for (const [key, example] of Object.entries(exampleMap)) { 2545 | if (nodeType.includes(key)) { 2546 | return example; 2547 | } 2548 | } 2549 | 2550 | // Generic example 2551 | return { 2552 | toolName: 'Custom Tool', 2553 | toolDescription: 'Performs specific operations. Describe what this tool does and when to use it.', 2554 | nodeConfig: { 2555 | note: 'Configure the node based on its specific requirements' 2556 | } 2557 | }; 2558 | } 2559 | 2560 | private async validateNodeMinimal(nodeType: string, config: Record<string, any>): Promise<any> { 2561 | await this.ensureInitialized(); 2562 | if (!this.repository) throw new Error('Repository not initialized'); 2563 | 2564 | // Get node info 2565 | // First try with normalized type 2566 | const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType); 2567 | let node = this.repository.getNode(normalizedType); 2568 | 2569 | if (!node && normalizedType !== nodeType) { 2570 | // Try original if normalization changed it 2571 | node = this.repository.getNode(nodeType); 2572 | } 2573 | 2574 | if (!node) { 2575 | // Fallback to other alternatives for edge cases 2576 | const alternatives = getNodeTypeAlternatives(normalizedType); 2577 | 2578 | for (const alt of alternatives) { 2579 | const found = this.repository!.getNode(alt); 2580 | if (found) { 2581 | node = found; 2582 | break; 2583 | } 2584 | } 2585 | } 2586 | 2587 | if (!node) { 2588 | throw new Error(`Node ${nodeType} not found`); 2589 | } 2590 | 2591 | // Get properties 2592 | const properties = node.properties || []; 2593 | 2594 | // Extract operation context (safely handle undefined config properties) 2595 | const operationContext = { 2596 | resource: config?.resource, 2597 | operation: config?.operation, 2598 | action: config?.action, 2599 | mode: config?.mode 2600 | }; 2601 | 2602 | // Find missing required fields 2603 | const missingFields: string[] = []; 2604 | 2605 | for (const prop of properties) { 2606 | // Skip if not required 2607 | if (!prop.required) continue; 2608 | 2609 | // Skip if not visible based on current config 2610 | if (prop.displayOptions) { 2611 | let isVisible = true; 2612 | 2613 | // Check show conditions 2614 | if (prop.displayOptions.show) { 2615 | for (const [key, values] of Object.entries(prop.displayOptions.show)) { 2616 | const configValue = config?.[key]; 2617 | const expectedValues = Array.isArray(values) ? values : [values]; 2618 | 2619 | if (!expectedValues.includes(configValue)) { 2620 | isVisible = false; 2621 | break; 2622 | } 2623 | } 2624 | } 2625 | 2626 | // Check hide conditions 2627 | if (isVisible && prop.displayOptions.hide) { 2628 | for (const [key, values] of Object.entries(prop.displayOptions.hide)) { 2629 | const configValue = config?.[key]; 2630 | const expectedValues = Array.isArray(values) ? values : [values]; 2631 | 2632 | if (expectedValues.includes(configValue)) { 2633 | isVisible = false; 2634 | break; 2635 | } 2636 | } 2637 | } 2638 | 2639 | if (!isVisible) continue; 2640 | } 2641 | 2642 | // Check if field is missing (safely handle null/undefined config) 2643 | if (!config || !(prop.name in config)) { 2644 | missingFields.push(prop.displayName || prop.name); 2645 | } 2646 | } 2647 | 2648 | return { 2649 | nodeType: node.nodeType, 2650 | displayName: node.displayName, 2651 | valid: missingFields.length === 0, 2652 | missingRequiredFields: missingFields 2653 | }; 2654 | } 2655 | 2656 | // Method removed - replaced by getToolsDocumentation 2657 | 2658 | private async getToolsDocumentation(topic?: string, depth: 'essentials' | 'full' = 'essentials'): Promise<string> { 2659 | if (!topic || topic === 'overview') { 2660 | return getToolsOverview(depth); 2661 | } 2662 | 2663 | return getToolDocumentation(topic, depth); 2664 | } 2665 | 2666 | // Add connect method to accept any transport 2667 | async connect(transport: any): Promise<void> { 2668 | await this.ensureInitialized(); 2669 | await this.server.connect(transport); 2670 | logger.info('MCP Server connected', { 2671 | transportType: transport.constructor.name 2672 | }); 2673 | } 2674 | 2675 | // Template-related methods 2676 | private async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views', includeMetadata: boolean = false): Promise<any> { 2677 | await this.ensureInitialized(); 2678 | if (!this.templateService) throw new Error('Template service not initialized'); 2679 | 2680 | const result = await this.templateService.listTemplates(limit, offset, sortBy, includeMetadata); 2681 | 2682 | return { 2683 | ...result, 2684 | tip: result.items.length > 0 ? 2685 | `Use get_template(templateId) to get full workflow details. Total: ${result.total} templates available.` : 2686 | "No templates found. Run 'npm run fetch:templates' to update template database" 2687 | }; 2688 | } 2689 | 2690 | private async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise<any> { 2691 | await this.ensureInitialized(); 2692 | if (!this.templateService) throw new Error('Template service not initialized'); 2693 | 2694 | const result = await this.templateService.listNodeTemplates(nodeTypes, limit, offset); 2695 | 2696 | if (result.items.length === 0 && offset === 0) { 2697 | return { 2698 | ...result, 2699 | message: `No templates found using nodes: ${nodeTypes.join(', ')}`, 2700 | tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database" 2701 | }; 2702 | } 2703 | 2704 | return { 2705 | ...result, 2706 | tip: `Showing ${result.items.length} of ${result.total} templates. Use offset for pagination.` 2707 | }; 2708 | } 2709 | 2710 | private async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise<any> { 2711 | await this.ensureInitialized(); 2712 | if (!this.templateService) throw new Error('Template service not initialized'); 2713 | 2714 | const template = await this.templateService.getTemplate(templateId, mode); 2715 | 2716 | if (!template) { 2717 | return { 2718 | error: `Template ${templateId} not found`, 2719 | tip: "Use list_templates, list_node_templates or search_templates to find available templates" 2720 | }; 2721 | } 2722 | 2723 | const usage = mode === 'nodes_only' ? "Node list for quick overview" : 2724 | mode === 'structure' ? "Workflow structure without full details" : 2725 | "Complete workflow JSON ready to import into n8n"; 2726 | 2727 | return { 2728 | mode, 2729 | template, 2730 | usage 2731 | }; 2732 | } 2733 | 2734 | private async searchTemplates(query: string, limit: number = 20, offset: number = 0, fields?: string[]): Promise<any> { 2735 | await this.ensureInitialized(); 2736 | if (!this.templateService) throw new Error('Template service not initialized'); 2737 | 2738 | const result = await this.templateService.searchTemplates(query, limit, offset, fields); 2739 | 2740 | if (result.items.length === 0 && offset === 0) { 2741 | return { 2742 | ...result, 2743 | message: `No templates found matching: "${query}"`, 2744 | tip: "Try different keywords or run 'npm run fetch:templates' to update template database" 2745 | }; 2746 | } 2747 | 2748 | return { 2749 | ...result, 2750 | query, 2751 | tip: `Found ${result.total} templates matching "${query}". Showing ${result.items.length}.` 2752 | }; 2753 | } 2754 | 2755 | private async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise<any> { 2756 | await this.ensureInitialized(); 2757 | if (!this.templateService) throw new Error('Template service not initialized'); 2758 | 2759 | const result = await this.templateService.getTemplatesForTask(task, limit, offset); 2760 | const availableTasks = this.templateService.listAvailableTasks(); 2761 | 2762 | if (result.items.length === 0 && offset === 0) { 2763 | return { 2764 | ...result, 2765 | message: `No templates found for task: ${task}`, 2766 | availableTasks, 2767 | tip: "Try a different task or use search_templates for custom searches" 2768 | }; 2769 | } 2770 | 2771 | return { 2772 | ...result, 2773 | task, 2774 | description: this.getTaskDescription(task), 2775 | tip: `${result.total} templates available for ${task}. Showing ${result.items.length}.` 2776 | }; 2777 | } 2778 | 2779 | private async searchTemplatesByMetadata(filters: { 2780 | category?: string; 2781 | complexity?: 'simple' | 'medium' | 'complex'; 2782 | maxSetupMinutes?: number; 2783 | minSetupMinutes?: number; 2784 | requiredService?: string; 2785 | targetAudience?: string; 2786 | }, limit: number = 20, offset: number = 0): Promise<any> { 2787 | await this.ensureInitialized(); 2788 | if (!this.templateService) throw new Error('Template service not initialized'); 2789 | 2790 | const result = await this.templateService.searchTemplatesByMetadata(filters, limit, offset); 2791 | 2792 | // Build filter summary for feedback 2793 | const filterSummary: string[] = []; 2794 | if (filters.category) filterSummary.push(`category: ${filters.category}`); 2795 | if (filters.complexity) filterSummary.push(`complexity: ${filters.complexity}`); 2796 | if (filters.maxSetupMinutes) filterSummary.push(`max setup: ${filters.maxSetupMinutes} min`); 2797 | if (filters.minSetupMinutes) filterSummary.push(`min setup: ${filters.minSetupMinutes} min`); 2798 | if (filters.requiredService) filterSummary.push(`service: ${filters.requiredService}`); 2799 | if (filters.targetAudience) filterSummary.push(`audience: ${filters.targetAudience}`); 2800 | 2801 | if (result.items.length === 0 && offset === 0) { 2802 | // Get available categories and audiences for suggestions 2803 | const availableCategories = await this.templateService.getAvailableCategories(); 2804 | const availableAudiences = await this.templateService.getAvailableTargetAudiences(); 2805 | 2806 | return { 2807 | ...result, 2808 | message: `No templates found with filters: ${filterSummary.join(', ')}`, 2809 | availableCategories: availableCategories.slice(0, 10), 2810 | availableAudiences: availableAudiences.slice(0, 5), 2811 | tip: "Try broader filters or different categories. Use list_templates to see all templates." 2812 | }; 2813 | } 2814 | 2815 | return { 2816 | ...result, 2817 | filters, 2818 | filterSummary: filterSummary.join(', '), 2819 | tip: `Found ${result.total} templates matching filters. Showing ${result.items.length}. Each includes AI-generated metadata.` 2820 | }; 2821 | } 2822 | 2823 | private getTaskDescription(task: string): string { 2824 | const descriptions: Record<string, string> = { 2825 | 'ai_automation': 'AI-powered workflows using OpenAI, LangChain, and other AI tools', 2826 | 'data_sync': 'Synchronize data between databases, spreadsheets, and APIs', 2827 | 'webhook_processing': 'Process incoming webhooks and trigger automated actions', 2828 | 'email_automation': 'Send, receive, and process emails automatically', 2829 | 'slack_integration': 'Integrate with Slack for notifications and bot interactions', 2830 | 'data_transformation': 'Transform, clean, and manipulate data', 2831 | 'file_processing': 'Handle file uploads, downloads, and transformations', 2832 | 'scheduling': 'Schedule recurring tasks and time-based automations', 2833 | 'api_integration': 'Connect to external APIs and web services', 2834 | 'database_operations': 'Query, insert, update, and manage database records' 2835 | }; 2836 | 2837 | return descriptions[task] || 'Workflow templates for this task'; 2838 | } 2839 | 2840 | private async validateWorkflow(workflow: any, options?: any): Promise<any> { 2841 | await this.ensureInitialized(); 2842 | if (!this.repository) throw new Error('Repository not initialized'); 2843 | 2844 | // Enhanced logging for workflow validation 2845 | logger.info('Workflow validation requested', { 2846 | hasWorkflow: !!workflow, 2847 | workflowType: typeof workflow, 2848 | hasNodes: workflow?.nodes !== undefined, 2849 | nodesType: workflow?.nodes ? typeof workflow.nodes : 'undefined', 2850 | nodesIsArray: Array.isArray(workflow?.nodes), 2851 | nodesCount: Array.isArray(workflow?.nodes) ? workflow.nodes.length : 0, 2852 | hasConnections: workflow?.connections !== undefined, 2853 | connectionsType: workflow?.connections ? typeof workflow.connections : 'undefined', 2854 | options: options 2855 | }); 2856 | 2857 | // Help n8n AI agents with common mistakes 2858 | if (!workflow || typeof workflow !== 'object') { 2859 | return { 2860 | valid: false, 2861 | errors: [{ 2862 | node: 'workflow', 2863 | message: 'Workflow must be an object with nodes and connections', 2864 | details: 'Expected format: ' + getWorkflowExampleString() 2865 | }], 2866 | summary: { errorCount: 1 } 2867 | }; 2868 | } 2869 | 2870 | if (!workflow.nodes || !Array.isArray(workflow.nodes)) { 2871 | return { 2872 | valid: false, 2873 | errors: [{ 2874 | node: 'workflow', 2875 | message: 'Workflow must have a nodes array', 2876 | details: 'Expected: workflow.nodes = [array of node objects]. ' + getWorkflowExampleString() 2877 | }], 2878 | summary: { errorCount: 1 } 2879 | }; 2880 | } 2881 | 2882 | if (!workflow.connections || typeof workflow.connections !== 'object') { 2883 | return { 2884 | valid: false, 2885 | errors: [{ 2886 | node: 'workflow', 2887 | message: 'Workflow must have a connections object', 2888 | details: 'Expected: workflow.connections = {} (can be empty object). ' + getWorkflowExampleString() 2889 | }], 2890 | summary: { errorCount: 1 } 2891 | }; 2892 | } 2893 | 2894 | // Create workflow validator instance 2895 | const validator = new WorkflowValidator( 2896 | this.repository, 2897 | EnhancedConfigValidator 2898 | ); 2899 | 2900 | try { 2901 | const result = await validator.validateWorkflow(workflow, options); 2902 | 2903 | // Format the response for better readability 2904 | const response: any = { 2905 | valid: result.valid, 2906 | summary: { 2907 | totalNodes: result.statistics.totalNodes, 2908 | enabledNodes: result.statistics.enabledNodes, 2909 | triggerNodes: result.statistics.triggerNodes, 2910 | validConnections: result.statistics.validConnections, 2911 | invalidConnections: result.statistics.invalidConnections, 2912 | expressionsValidated: result.statistics.expressionsValidated, 2913 | errorCount: result.errors.length, 2914 | warningCount: result.warnings.length 2915 | }, 2916 | // Always include errors and warnings arrays for consistent API response 2917 | errors: result.errors.map(e => ({ 2918 | node: e.nodeName || 'workflow', 2919 | message: e.message, 2920 | details: e.details 2921 | })), 2922 | warnings: result.warnings.map(w => ({ 2923 | node: w.nodeName || 'workflow', 2924 | message: w.message, 2925 | details: w.details 2926 | })) 2927 | }; 2928 | 2929 | if (result.suggestions.length > 0) { 2930 | response.suggestions = result.suggestions; 2931 | } 2932 | 2933 | // Track validation details in telemetry 2934 | if (!result.valid && result.errors.length > 0) { 2935 | // Track each validation error for analysis 2936 | result.errors.forEach(error => { 2937 | telemetry.trackValidationDetails( 2938 | error.nodeName || 'workflow', 2939 | error.type || 'validation_error', 2940 | { 2941 | message: error.message, 2942 | nodeCount: workflow.nodes?.length ?? 0, 2943 | hasConnections: Object.keys(workflow.connections || {}).length > 0 2944 | } 2945 | ); 2946 | }); 2947 | } 2948 | 2949 | // Track successfully validated workflows in telemetry 2950 | if (result.valid) { 2951 | telemetry.trackWorkflowCreation(workflow, true); 2952 | } 2953 | 2954 | return response; 2955 | } catch (error) { 2956 | logger.error('Error validating workflow:', error); 2957 | return { 2958 | valid: false, 2959 | error: error instanceof Error ? error.message : 'Unknown error validating workflow', 2960 | tip: 'Ensure the workflow JSON includes nodes array and connections object' 2961 | }; 2962 | } 2963 | } 2964 | 2965 | private async validateWorkflowConnections(workflow: any): Promise<any> { 2966 | await this.ensureInitialized(); 2967 | if (!this.repository) throw new Error('Repository not initialized'); 2968 | 2969 | // Create workflow validator instance 2970 | const validator = new WorkflowValidator( 2971 | this.repository, 2972 | EnhancedConfigValidator 2973 | ); 2974 | 2975 | try { 2976 | // Validate only connections 2977 | const result = await validator.validateWorkflow(workflow, { 2978 | validateNodes: false, 2979 | validateConnections: true, 2980 | validateExpressions: false 2981 | }); 2982 | 2983 | const response: any = { 2984 | valid: result.errors.length === 0, 2985 | statistics: { 2986 | totalNodes: result.statistics.totalNodes, 2987 | triggerNodes: result.statistics.triggerNodes, 2988 | validConnections: result.statistics.validConnections, 2989 | invalidConnections: result.statistics.invalidConnections 2990 | } 2991 | }; 2992 | 2993 | // Filter to only connection-related issues 2994 | const connectionErrors = result.errors.filter(e => 2995 | e.message.includes('connection') || 2996 | e.message.includes('cycle') || 2997 | e.message.includes('orphaned') 2998 | ); 2999 | 3000 | const connectionWarnings = result.warnings.filter(w => 3001 | w.message.includes('connection') || 3002 | w.message.includes('orphaned') || 3003 | w.message.includes('trigger') 3004 | ); 3005 | 3006 | if (connectionErrors.length > 0) { 3007 | response.errors = connectionErrors.map(e => ({ 3008 | node: e.nodeName || 'workflow', 3009 | message: e.message 3010 | })); 3011 | } 3012 | 3013 | if (connectionWarnings.length > 0) { 3014 | response.warnings = connectionWarnings.map(w => ({ 3015 | node: w.nodeName || 'workflow', 3016 | message: w.message 3017 | })); 3018 | } 3019 | 3020 | return response; 3021 | } catch (error) { 3022 | logger.error('Error validating workflow connections:', error); 3023 | return { 3024 | valid: false, 3025 | error: error instanceof Error ? error.message : 'Unknown error validating connections' 3026 | }; 3027 | } 3028 | } 3029 | 3030 | private async validateWorkflowExpressions(workflow: any): Promise<any> { 3031 | await this.ensureInitialized(); 3032 | if (!this.repository) throw new Error('Repository not initialized'); 3033 | 3034 | // Create workflow validator instance 3035 | const validator = new WorkflowValidator( 3036 | this.repository, 3037 | EnhancedConfigValidator 3038 | ); 3039 | 3040 | try { 3041 | // Validate only expressions 3042 | const result = await validator.validateWorkflow(workflow, { 3043 | validateNodes: false, 3044 | validateConnections: false, 3045 | validateExpressions: true 3046 | }); 3047 | 3048 | const response: any = { 3049 | valid: result.errors.length === 0, 3050 | statistics: { 3051 | totalNodes: result.statistics.totalNodes, 3052 | expressionsValidated: result.statistics.expressionsValidated 3053 | } 3054 | }; 3055 | 3056 | // Filter to only expression-related issues 3057 | const expressionErrors = result.errors.filter(e => 3058 | e.message.includes('Expression') || 3059 | e.message.includes('$') || 3060 | e.message.includes('{{') 3061 | ); 3062 | 3063 | const expressionWarnings = result.warnings.filter(w => 3064 | w.message.includes('Expression') || 3065 | w.message.includes('$') || 3066 | w.message.includes('{{') 3067 | ); 3068 | 3069 | if (expressionErrors.length > 0) { 3070 | response.errors = expressionErrors.map(e => ({ 3071 | node: e.nodeName || 'workflow', 3072 | message: e.message 3073 | })); 3074 | } 3075 | 3076 | if (expressionWarnings.length > 0) { 3077 | response.warnings = expressionWarnings.map(w => ({ 3078 | node: w.nodeName || 'workflow', 3079 | message: w.message 3080 | })); 3081 | } 3082 | 3083 | // Add tips for common expression issues 3084 | if (expressionErrors.length > 0 || expressionWarnings.length > 0) { 3085 | response.tips = [ 3086 | 'Use {{ }} to wrap expressions', 3087 | 'Reference data with $json.propertyName', 3088 | 'Reference other nodes with $node["Node Name"].json', 3089 | 'Use $input.item for input data in loops' 3090 | ]; 3091 | } 3092 | 3093 | return response; 3094 | } catch (error) { 3095 | logger.error('Error validating workflow expressions:', error); 3096 | return { 3097 | valid: false, 3098 | error: error instanceof Error ? error.message : 'Unknown error validating expressions' 3099 | }; 3100 | } 3101 | } 3102 | 3103 | async run(): Promise<void> { 3104 | // Ensure database is initialized before starting server 3105 | await this.ensureInitialized(); 3106 | 3107 | const transport = new StdioServerTransport(); 3108 | await this.server.connect(transport); 3109 | 3110 | // Force flush stdout for Docker environments 3111 | // Docker uses block buffering which can delay MCP responses 3112 | if (!process.stdout.isTTY || process.env.IS_DOCKER) { 3113 | // Override write to auto-flush 3114 | const originalWrite = process.stdout.write.bind(process.stdout); 3115 | process.stdout.write = function(chunk: any, encoding?: any, callback?: any) { 3116 | const result = originalWrite(chunk, encoding, callback); 3117 | // Force immediate flush 3118 | process.stdout.emit('drain'); 3119 | return result; 3120 | }; 3121 | } 3122 | 3123 | logger.info('n8n Documentation MCP Server running on stdio transport'); 3124 | 3125 | // Keep the process alive and listening 3126 | process.stdin.resume(); 3127 | } 3128 | 3129 | async shutdown(): Promise<void> { 3130 | logger.info('Shutting down MCP server...'); 3131 | 3132 | // Clean up cache timers to prevent memory leaks 3133 | if (this.cache) { 3134 | try { 3135 | this.cache.destroy(); 3136 | logger.info('Cache timers cleaned up'); 3137 | } catch (error) { 3138 | logger.error('Error cleaning up cache:', error); 3139 | } 3140 | } 3141 | 3142 | // Close database connection if it exists 3143 | if (this.db) { 3144 | try { 3145 | await this.db.close(); 3146 | logger.info('Database connection closed'); 3147 | } catch (error) { 3148 | logger.error('Error closing database:', error); 3149 | } 3150 | } 3151 | } 3152 | } ```