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