#
tokens: 46707/50000 9/617 files (page 21/59)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 21 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── _config.yml
├── .claude
│   └── agents
│       ├── code-reviewer.md
│       ├── context-manager.md
│       ├── debugger.md
│       ├── deployment-engineer.md
│       ├── mcp-backend-engineer.md
│       ├── n8n-mcp-tester.md
│       ├── technical-researcher.md
│       └── test-automator.md
├── .dockerignore
├── .env.docker
├── .env.example
├── .env.n8n.example
├── .env.test
├── .env.test.example
├── .github
│   ├── ABOUT.md
│   ├── BENCHMARK_THRESHOLDS.md
│   ├── FUNDING.yml
│   ├── gh-pages.yml
│   ├── secret_scanning.yml
│   └── workflows
│       ├── benchmark-pr.yml
│       ├── benchmark.yml
│       ├── docker-build-fast.yml
│       ├── docker-build-n8n.yml
│       ├── docker-build.yml
│       ├── release.yml
│       ├── test.yml
│       └── update-n8n-deps.yml
├── .gitignore
├── .npmignore
├── ATTRIBUTION.md
├── CHANGELOG.md
├── CLAUDE.md
├── codecov.yml
├── coverage.json
├── data
│   ├── .gitkeep
│   ├── nodes.db
│   ├── nodes.db-shm
│   ├── nodes.db-wal
│   └── templates.db
├── deploy
│   └── quick-deploy-n8n.sh
├── docker
│   ├── docker-entrypoint.sh
│   ├── n8n-mcp
│   ├── parse-config.js
│   └── README.md
├── docker-compose.buildkit.yml
├── docker-compose.extract.yml
├── docker-compose.n8n.yml
├── docker-compose.override.yml.example
├── docker-compose.test-n8n.yml
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.railway
├── Dockerfile.test
├── docs
│   ├── AUTOMATED_RELEASES.md
│   ├── BENCHMARKS.md
│   ├── CHANGELOG.md
│   ├── CLAUDE_CODE_SETUP.md
│   ├── CLAUDE_INTERVIEW.md
│   ├── CODECOV_SETUP.md
│   ├── CODEX_SETUP.md
│   ├── CURSOR_SETUP.md
│   ├── DEPENDENCY_UPDATES.md
│   ├── DOCKER_README.md
│   ├── DOCKER_TROUBLESHOOTING.md
│   ├── FINAL_AI_VALIDATION_SPEC.md
│   ├── FLEXIBLE_INSTANCE_CONFIGURATION.md
│   ├── HTTP_DEPLOYMENT.md
│   ├── img
│   │   ├── cc_command.png
│   │   ├── cc_connected.png
│   │   ├── codex_connected.png
│   │   ├── cursor_tut.png
│   │   ├── Railway_api.png
│   │   ├── Railway_server_address.png
│   │   ├── vsc_ghcp_chat_agent_mode.png
│   │   ├── vsc_ghcp_chat_instruction_files.png
│   │   ├── vsc_ghcp_chat_thinking_tool.png
│   │   └── windsurf_tut.png
│   ├── INSTALLATION.md
│   ├── LIBRARY_USAGE.md
│   ├── local
│   │   ├── DEEP_DIVE_ANALYSIS_2025-10-02.md
│   │   ├── DEEP_DIVE_ANALYSIS_README.md
│   │   ├── Deep_dive_p1_p2.md
│   │   ├── integration-testing-plan.md
│   │   ├── integration-tests-phase1-summary.md
│   │   ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
│   │   ├── P0_IMPLEMENTATION_PLAN.md
│   │   └── TEMPLATE_MINING_ANALYSIS.md
│   ├── MCP_ESSENTIALS_README.md
│   ├── MCP_QUICK_START_GUIDE.md
│   ├── N8N_DEPLOYMENT.md
│   ├── RAILWAY_DEPLOYMENT.md
│   ├── README_CLAUDE_SETUP.md
│   ├── README.md
│   ├── tools-documentation-usage.md
│   ├── VS_CODE_PROJECT_SETUP.md
│   ├── WINDSURF_SETUP.md
│   └── workflow-diff-examples.md
├── examples
│   └── enhanced-documentation-demo.js
├── fetch_log.txt
├── LICENSE
├── MEMORY_N8N_UPDATE.md
├── MEMORY_TEMPLATE_UPDATE.md
├── monitor_fetch.sh
├── N8N_HTTP_STREAMABLE_SETUP.md
├── n8n-nodes.db
├── P0-R3-TEST-PLAN.md
├── package-lock.json
├── package.json
├── package.runtime.json
├── PRIVACY.md
├── railway.json
├── README.md
├── renovate.json
├── scripts
│   ├── analyze-optimization.sh
│   ├── audit-schema-coverage.ts
│   ├── build-optimized.sh
│   ├── compare-benchmarks.js
│   ├── demo-optimization.sh
│   ├── deploy-http.sh
│   ├── deploy-to-vm.sh
│   ├── export-webhook-workflows.ts
│   ├── extract-changelog.js
│   ├── extract-from-docker.js
│   ├── extract-nodes-docker.sh
│   ├── extract-nodes-simple.sh
│   ├── format-benchmark-results.js
│   ├── generate-benchmark-stub.js
│   ├── generate-detailed-reports.js
│   ├── generate-test-summary.js
│   ├── http-bridge.js
│   ├── mcp-http-client.js
│   ├── migrate-nodes-fts.ts
│   ├── migrate-tool-docs.ts
│   ├── n8n-docs-mcp.service
│   ├── nginx-n8n-mcp.conf
│   ├── prebuild-fts5.ts
│   ├── prepare-release.js
│   ├── publish-npm-quick.sh
│   ├── publish-npm.sh
│   ├── quick-test.ts
│   ├── run-benchmarks-ci.js
│   ├── sync-runtime-version.js
│   ├── test-ai-validation-debug.ts
│   ├── test-code-node-enhancements.ts
│   ├── test-code-node-fixes.ts
│   ├── test-docker-config.sh
│   ├── test-docker-fingerprint.ts
│   ├── test-docker-optimization.sh
│   ├── test-docker.sh
│   ├── test-empty-connection-validation.ts
│   ├── test-error-message-tracking.ts
│   ├── test-error-output-validation.ts
│   ├── test-error-validation.js
│   ├── test-essentials.ts
│   ├── test-expression-code-validation.ts
│   ├── test-expression-format-validation.js
│   ├── test-fts5-search.ts
│   ├── test-fuzzy-fix.ts
│   ├── test-fuzzy-simple.ts
│   ├── test-helpers-validation.ts
│   ├── test-http-search.ts
│   ├── test-http.sh
│   ├── test-jmespath-validation.ts
│   ├── test-multi-tenant-simple.ts
│   ├── test-multi-tenant.ts
│   ├── test-n8n-integration.sh
│   ├── test-node-info.js
│   ├── test-node-type-validation.ts
│   ├── test-nodes-base-prefix.ts
│   ├── test-operation-validation.ts
│   ├── test-optimized-docker.sh
│   ├── test-release-automation.js
│   ├── test-search-improvements.ts
│   ├── test-security.ts
│   ├── test-single-session.sh
│   ├── test-sqljs-triggers.ts
│   ├── test-telemetry-debug.ts
│   ├── test-telemetry-direct.ts
│   ├── test-telemetry-env.ts
│   ├── test-telemetry-integration.ts
│   ├── test-telemetry-no-select.ts
│   ├── test-telemetry-security.ts
│   ├── test-telemetry-simple.ts
│   ├── test-typeversion-validation.ts
│   ├── test-url-configuration.ts
│   ├── test-user-id-persistence.ts
│   ├── test-webhook-validation.ts
│   ├── test-workflow-insert.ts
│   ├── test-workflow-sanitizer.ts
│   ├── test-workflow-tracking-debug.ts
│   ├── update-and-publish-prep.sh
│   ├── update-n8n-deps.js
│   ├── update-readme-version.js
│   ├── vitest-benchmark-json-reporter.js
│   └── vitest-benchmark-reporter.ts
├── SECURITY.md
├── src
│   ├── config
│   │   └── n8n-api.ts
│   ├── data
│   │   └── canonical-ai-tool-examples.json
│   ├── database
│   │   ├── database-adapter.ts
│   │   ├── migrations
│   │   │   └── add-template-node-configs.sql
│   │   ├── node-repository.ts
│   │   ├── nodes.db
│   │   ├── schema-optimized.sql
│   │   └── schema.sql
│   ├── errors
│   │   └── validation-service-error.ts
│   ├── http-server-single-session.ts
│   ├── http-server.ts
│   ├── index.ts
│   ├── loaders
│   │   └── node-loader.ts
│   ├── mappers
│   │   └── docs-mapper.ts
│   ├── mcp
│   │   ├── handlers-n8n-manager.ts
│   │   ├── handlers-workflow-diff.ts
│   │   ├── index.ts
│   │   ├── server.ts
│   │   ├── stdio-wrapper.ts
│   │   ├── tool-docs
│   │   │   ├── configuration
│   │   │   │   ├── get-node-as-tool-info.ts
│   │   │   │   ├── get-node-documentation.ts
│   │   │   │   ├── get-node-essentials.ts
│   │   │   │   ├── get-node-info.ts
│   │   │   │   ├── get-property-dependencies.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── search-node-properties.ts
│   │   │   ├── discovery
│   │   │   │   ├── get-database-statistics.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── list-ai-tools.ts
│   │   │   │   ├── list-nodes.ts
│   │   │   │   └── search-nodes.ts
│   │   │   ├── guides
│   │   │   │   ├── ai-agents-guide.ts
│   │   │   │   └── index.ts
│   │   │   ├── index.ts
│   │   │   ├── system
│   │   │   │   ├── index.ts
│   │   │   │   ├── n8n-diagnostic.ts
│   │   │   │   ├── n8n-health-check.ts
│   │   │   │   ├── n8n-list-available-tools.ts
│   │   │   │   └── tools-documentation.ts
│   │   │   ├── templates
│   │   │   │   ├── get-template.ts
│   │   │   │   ├── get-templates-for-task.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── list-node-templates.ts
│   │   │   │   ├── list-tasks.ts
│   │   │   │   ├── search-templates-by-metadata.ts
│   │   │   │   └── search-templates.ts
│   │   │   ├── types.ts
│   │   │   ├── validation
│   │   │   │   ├── index.ts
│   │   │   │   ├── validate-node-minimal.ts
│   │   │   │   ├── validate-node-operation.ts
│   │   │   │   ├── validate-workflow-connections.ts
│   │   │   │   ├── validate-workflow-expressions.ts
│   │   │   │   └── validate-workflow.ts
│   │   │   └── workflow_management
│   │   │       ├── index.ts
│   │   │       ├── n8n-autofix-workflow.ts
│   │   │       ├── n8n-create-workflow.ts
│   │   │       ├── n8n-delete-execution.ts
│   │   │       ├── n8n-delete-workflow.ts
│   │   │       ├── n8n-get-execution.ts
│   │   │       ├── n8n-get-workflow-details.ts
│   │   │       ├── n8n-get-workflow-minimal.ts
│   │   │       ├── n8n-get-workflow-structure.ts
│   │   │       ├── n8n-get-workflow.ts
│   │   │       ├── n8n-list-executions.ts
│   │   │       ├── n8n-list-workflows.ts
│   │   │       ├── n8n-trigger-webhook-workflow.ts
│   │   │       ├── n8n-update-full-workflow.ts
│   │   │       ├── n8n-update-partial-workflow.ts
│   │   │       └── n8n-validate-workflow.ts
│   │   ├── tools-documentation.ts
│   │   ├── tools-n8n-friendly.ts
│   │   ├── tools-n8n-manager.ts
│   │   ├── tools.ts
│   │   └── workflow-examples.ts
│   ├── mcp-engine.ts
│   ├── mcp-tools-engine.ts
│   ├── n8n
│   │   ├── MCPApi.credentials.ts
│   │   └── MCPNode.node.ts
│   ├── parsers
│   │   ├── node-parser.ts
│   │   ├── property-extractor.ts
│   │   └── simple-parser.ts
│   ├── scripts
│   │   ├── debug-http-search.ts
│   │   ├── extract-from-docker.ts
│   │   ├── fetch-templates-robust.ts
│   │   ├── fetch-templates.ts
│   │   ├── rebuild-database.ts
│   │   ├── rebuild-optimized.ts
│   │   ├── rebuild.ts
│   │   ├── sanitize-templates.ts
│   │   ├── seed-canonical-ai-examples.ts
│   │   ├── test-autofix-documentation.ts
│   │   ├── test-autofix-workflow.ts
│   │   ├── test-execution-filtering.ts
│   │   ├── test-node-suggestions.ts
│   │   ├── test-protocol-negotiation.ts
│   │   ├── test-summary.ts
│   │   ├── test-webhook-autofix.ts
│   │   ├── validate.ts
│   │   └── validation-summary.ts
│   ├── services
│   │   ├── ai-node-validator.ts
│   │   ├── ai-tool-validators.ts
│   │   ├── confidence-scorer.ts
│   │   ├── config-validator.ts
│   │   ├── enhanced-config-validator.ts
│   │   ├── example-generator.ts
│   │   ├── execution-processor.ts
│   │   ├── expression-format-validator.ts
│   │   ├── expression-validator.ts
│   │   ├── n8n-api-client.ts
│   │   ├── n8n-validation.ts
│   │   ├── node-documentation-service.ts
│   │   ├── node-sanitizer.ts
│   │   ├── node-similarity-service.ts
│   │   ├── node-specific-validators.ts
│   │   ├── operation-similarity-service.ts
│   │   ├── property-dependencies.ts
│   │   ├── property-filter.ts
│   │   ├── resource-similarity-service.ts
│   │   ├── sqlite-storage-service.ts
│   │   ├── task-templates.ts
│   │   ├── universal-expression-validator.ts
│   │   ├── workflow-auto-fixer.ts
│   │   ├── workflow-diff-engine.ts
│   │   └── workflow-validator.ts
│   ├── telemetry
│   │   ├── batch-processor.ts
│   │   ├── config-manager.ts
│   │   ├── early-error-logger.ts
│   │   ├── error-sanitization-utils.ts
│   │   ├── error-sanitizer.ts
│   │   ├── event-tracker.ts
│   │   ├── event-validator.ts
│   │   ├── index.ts
│   │   ├── performance-monitor.ts
│   │   ├── rate-limiter.ts
│   │   ├── startup-checkpoints.ts
│   │   ├── telemetry-error.ts
│   │   ├── telemetry-manager.ts
│   │   ├── telemetry-types.ts
│   │   └── workflow-sanitizer.ts
│   ├── templates
│   │   ├── batch-processor.ts
│   │   ├── metadata-generator.ts
│   │   ├── README.md
│   │   ├── template-fetcher.ts
│   │   ├── template-repository.ts
│   │   └── template-service.ts
│   ├── types
│   │   ├── index.ts
│   │   ├── instance-context.ts
│   │   ├── n8n-api.ts
│   │   ├── node-types.ts
│   │   └── workflow-diff.ts
│   └── utils
│       ├── auth.ts
│       ├── bridge.ts
│       ├── cache-utils.ts
│       ├── console-manager.ts
│       ├── documentation-fetcher.ts
│       ├── enhanced-documentation-fetcher.ts
│       ├── error-handler.ts
│       ├── example-generator.ts
│       ├── fixed-collection-validator.ts
│       ├── logger.ts
│       ├── mcp-client.ts
│       ├── n8n-errors.ts
│       ├── node-source-extractor.ts
│       ├── node-type-normalizer.ts
│       ├── node-type-utils.ts
│       ├── node-utils.ts
│       ├── npm-version-checker.ts
│       ├── protocol-version.ts
│       ├── simple-cache.ts
│       ├── ssrf-protection.ts
│       ├── template-node-resolver.ts
│       ├── template-sanitizer.ts
│       ├── url-detector.ts
│       ├── validation-schemas.ts
│       └── version.ts
├── test-output.txt
├── test-reinit-fix.sh
├── tests
│   ├── __snapshots__
│   │   └── .gitkeep
│   ├── auth.test.ts
│   ├── benchmarks
│   │   ├── database-queries.bench.ts
│   │   ├── index.ts
│   │   ├── mcp-tools.bench.ts
│   │   ├── mcp-tools.bench.ts.disabled
│   │   ├── mcp-tools.bench.ts.skip
│   │   ├── node-loading.bench.ts.disabled
│   │   ├── README.md
│   │   ├── search-operations.bench.ts.disabled
│   │   └── validation-performance.bench.ts.disabled
│   ├── bridge.test.ts
│   ├── comprehensive-extraction-test.js
│   ├── data
│   │   └── .gitkeep
│   ├── debug-slack-doc.js
│   ├── demo-enhanced-documentation.js
│   ├── docker-tests-README.md
│   ├── error-handler.test.ts
│   ├── examples
│   │   └── using-database-utils.test.ts
│   ├── extracted-nodes-db
│   │   ├── database-import.json
│   │   ├── extraction-report.json
│   │   ├── insert-nodes.sql
│   │   ├── n8n-nodes-base__Airtable.json
│   │   ├── n8n-nodes-base__Discord.json
│   │   ├── n8n-nodes-base__Function.json
│   │   ├── n8n-nodes-base__HttpRequest.json
│   │   ├── n8n-nodes-base__If.json
│   │   ├── n8n-nodes-base__Slack.json
│   │   ├── n8n-nodes-base__SplitInBatches.json
│   │   └── n8n-nodes-base__Webhook.json
│   ├── factories
│   │   ├── node-factory.ts
│   │   └── property-definition-factory.ts
│   ├── fixtures
│   │   ├── .gitkeep
│   │   ├── database
│   │   │   └── test-nodes.json
│   │   ├── factories
│   │   │   ├── node.factory.ts
│   │   │   └── parser-node.factory.ts
│   │   └── template-configs.ts
│   ├── helpers
│   │   └── env-helpers.ts
│   ├── http-server-auth.test.ts
│   ├── integration
│   │   ├── ai-validation
│   │   │   ├── ai-agent-validation.test.ts
│   │   │   ├── ai-tool-validation.test.ts
│   │   │   ├── chat-trigger-validation.test.ts
│   │   │   ├── e2e-validation.test.ts
│   │   │   ├── helpers.ts
│   │   │   ├── llm-chain-validation.test.ts
│   │   │   ├── README.md
│   │   │   └── TEST_REPORT.md
│   │   ├── ci
│   │   │   └── database-population.test.ts
│   │   ├── database
│   │   │   ├── connection-management.test.ts
│   │   │   ├── empty-database.test.ts
│   │   │   ├── fts5-search.test.ts
│   │   │   ├── node-fts5-search.test.ts
│   │   │   ├── node-repository.test.ts
│   │   │   ├── performance.test.ts
│   │   │   ├── sqljs-memory-leak.test.ts
│   │   │   ├── template-node-configs.test.ts
│   │   │   ├── template-repository.test.ts
│   │   │   ├── test-utils.ts
│   │   │   └── transactions.test.ts
│   │   ├── database-integration.test.ts
│   │   ├── docker
│   │   │   ├── docker-config.test.ts
│   │   │   ├── docker-entrypoint.test.ts
│   │   │   └── test-helpers.ts
│   │   ├── flexible-instance-config.test.ts
│   │   ├── mcp
│   │   │   └── template-examples-e2e.test.ts
│   │   ├── mcp-protocol
│   │   │   ├── basic-connection.test.ts
│   │   │   ├── error-handling.test.ts
│   │   │   ├── performance.test.ts
│   │   │   ├── protocol-compliance.test.ts
│   │   │   ├── README.md
│   │   │   ├── session-management.test.ts
│   │   │   ├── test-helpers.ts
│   │   │   ├── tool-invocation.test.ts
│   │   │   └── workflow-error-validation.test.ts
│   │   ├── msw-setup.test.ts
│   │   ├── n8n-api
│   │   │   ├── executions
│   │   │   │   ├── delete-execution.test.ts
│   │   │   │   ├── get-execution.test.ts
│   │   │   │   ├── list-executions.test.ts
│   │   │   │   └── trigger-webhook.test.ts
│   │   │   ├── scripts
│   │   │   │   └── cleanup-orphans.ts
│   │   │   ├── system
│   │   │   │   ├── diagnostic.test.ts
│   │   │   │   ├── health-check.test.ts
│   │   │   │   └── list-tools.test.ts
│   │   │   ├── test-connection.ts
│   │   │   ├── types
│   │   │   │   └── mcp-responses.ts
│   │   │   ├── utils
│   │   │   │   ├── cleanup-helpers.ts
│   │   │   │   ├── credentials.ts
│   │   │   │   ├── factories.ts
│   │   │   │   ├── fixtures.ts
│   │   │   │   ├── mcp-context.ts
│   │   │   │   ├── n8n-client.ts
│   │   │   │   ├── node-repository.ts
│   │   │   │   ├── response-types.ts
│   │   │   │   ├── test-context.ts
│   │   │   │   └── webhook-workflows.ts
│   │   │   └── workflows
│   │   │       ├── autofix-workflow.test.ts
│   │   │       ├── create-workflow.test.ts
│   │   │       ├── delete-workflow.test.ts
│   │   │       ├── get-workflow-details.test.ts
│   │   │       ├── get-workflow-minimal.test.ts
│   │   │       ├── get-workflow-structure.test.ts
│   │   │       ├── get-workflow.test.ts
│   │   │       ├── list-workflows.test.ts
│   │   │       ├── smart-parameters.test.ts
│   │   │       ├── update-partial-workflow.test.ts
│   │   │       ├── update-workflow.test.ts
│   │   │       └── validate-workflow.test.ts
│   │   ├── security
│   │   │   ├── command-injection-prevention.test.ts
│   │   │   └── rate-limiting.test.ts
│   │   ├── setup
│   │   │   ├── integration-setup.ts
│   │   │   └── msw-test-server.ts
│   │   ├── telemetry
│   │   │   ├── docker-user-id-stability.test.ts
│   │   │   └── mcp-telemetry.test.ts
│   │   ├── templates
│   │   │   └── metadata-operations.test.ts
│   │   └── workflow-creation-node-type-format.test.ts
│   ├── logger.test.ts
│   ├── MOCKING_STRATEGY.md
│   ├── mocks
│   │   ├── n8n-api
│   │   │   ├── data
│   │   │   │   ├── credentials.ts
│   │   │   │   ├── executions.ts
│   │   │   │   └── workflows.ts
│   │   │   ├── handlers.ts
│   │   │   └── index.ts
│   │   └── README.md
│   ├── node-storage-export.json
│   ├── setup
│   │   ├── global-setup.ts
│   │   ├── msw-setup.ts
│   │   ├── TEST_ENV_DOCUMENTATION.md
│   │   └── test-env.ts
│   ├── test-database-extraction.js
│   ├── test-direct-extraction.js
│   ├── test-enhanced-documentation.js
│   ├── test-enhanced-integration.js
│   ├── test-mcp-extraction.js
│   ├── test-mcp-server-extraction.js
│   ├── test-mcp-tools-integration.js
│   ├── test-node-documentation-service.js
│   ├── test-node-list.js
│   ├── test-package-info.js
│   ├── test-parsing-operations.js
│   ├── test-slack-node-complete.js
│   ├── test-small-rebuild.js
│   ├── test-sqlite-search.js
│   ├── test-storage-system.js
│   ├── unit
│   │   ├── __mocks__
│   │   │   ├── n8n-nodes-base.test.ts
│   │   │   ├── n8n-nodes-base.ts
│   │   │   └── README.md
│   │   ├── database
│   │   │   ├── __mocks__
│   │   │   │   └── better-sqlite3.ts
│   │   │   ├── database-adapter-unit.test.ts
│   │   │   ├── node-repository-core.test.ts
│   │   │   ├── node-repository-operations.test.ts
│   │   │   ├── node-repository-outputs.test.ts
│   │   │   ├── README.md
│   │   │   └── template-repository-core.test.ts
│   │   ├── docker
│   │   │   ├── config-security.test.ts
│   │   │   ├── edge-cases.test.ts
│   │   │   ├── parse-config.test.ts
│   │   │   └── serve-command.test.ts
│   │   ├── errors
│   │   │   └── validation-service-error.test.ts
│   │   ├── examples
│   │   │   └── using-n8n-nodes-base-mock.test.ts
│   │   ├── flexible-instance-security-advanced.test.ts
│   │   ├── flexible-instance-security.test.ts
│   │   ├── http-server
│   │   │   └── multi-tenant-support.test.ts
│   │   ├── http-server-n8n-mode.test.ts
│   │   ├── http-server-n8n-reinit.test.ts
│   │   ├── http-server-session-management.test.ts
│   │   ├── loaders
│   │   │   └── node-loader.test.ts
│   │   ├── mappers
│   │   │   └── docs-mapper.test.ts
│   │   ├── mcp
│   │   │   ├── get-node-essentials-examples.test.ts
│   │   │   ├── handlers-n8n-manager-simple.test.ts
│   │   │   ├── handlers-n8n-manager.test.ts
│   │   │   ├── handlers-workflow-diff.test.ts
│   │   │   ├── lru-cache-behavior.test.ts
│   │   │   ├── multi-tenant-tool-listing.test.ts.disabled
│   │   │   ├── parameter-validation.test.ts
│   │   │   ├── search-nodes-examples.test.ts
│   │   │   ├── tools-documentation.test.ts
│   │   │   └── tools.test.ts
│   │   ├── monitoring
│   │   │   └── cache-metrics.test.ts
│   │   ├── MULTI_TENANT_TEST_COVERAGE.md
│   │   ├── multi-tenant-integration.test.ts
│   │   ├── parsers
│   │   │   ├── node-parser-outputs.test.ts
│   │   │   ├── node-parser.test.ts
│   │   │   ├── property-extractor.test.ts
│   │   │   └── simple-parser.test.ts
│   │   ├── scripts
│   │   │   └── fetch-templates-extraction.test.ts
│   │   ├── services
│   │   │   ├── ai-node-validator.test.ts
│   │   │   ├── ai-tool-validators.test.ts
│   │   │   ├── confidence-scorer.test.ts
│   │   │   ├── config-validator-basic.test.ts
│   │   │   ├── config-validator-edge-cases.test.ts
│   │   │   ├── config-validator-node-specific.test.ts
│   │   │   ├── config-validator-security.test.ts
│   │   │   ├── debug-validator.test.ts
│   │   │   ├── enhanced-config-validator-integration.test.ts
│   │   │   ├── enhanced-config-validator-operations.test.ts
│   │   │   ├── enhanced-config-validator.test.ts
│   │   │   ├── example-generator.test.ts
│   │   │   ├── execution-processor.test.ts
│   │   │   ├── expression-format-validator.test.ts
│   │   │   ├── expression-validator-edge-cases.test.ts
│   │   │   ├── expression-validator.test.ts
│   │   │   ├── fixed-collection-validation.test.ts
│   │   │   ├── loop-output-edge-cases.test.ts
│   │   │   ├── n8n-api-client.test.ts
│   │   │   ├── n8n-validation.test.ts
│   │   │   ├── node-sanitizer.test.ts
│   │   │   ├── node-similarity-service.test.ts
│   │   │   ├── node-specific-validators.test.ts
│   │   │   ├── operation-similarity-service-comprehensive.test.ts
│   │   │   ├── operation-similarity-service.test.ts
│   │   │   ├── property-dependencies.test.ts
│   │   │   ├── property-filter-edge-cases.test.ts
│   │   │   ├── property-filter.test.ts
│   │   │   ├── resource-similarity-service-comprehensive.test.ts
│   │   │   ├── resource-similarity-service.test.ts
│   │   │   ├── task-templates.test.ts
│   │   │   ├── template-service.test.ts
│   │   │   ├── universal-expression-validator.test.ts
│   │   │   ├── validation-fixes.test.ts
│   │   │   ├── workflow-auto-fixer.test.ts
│   │   │   ├── workflow-diff-engine.test.ts
│   │   │   ├── workflow-fixed-collection-validation.test.ts
│   │   │   ├── workflow-validator-comprehensive.test.ts
│   │   │   ├── workflow-validator-edge-cases.test.ts
│   │   │   ├── workflow-validator-error-outputs.test.ts
│   │   │   ├── workflow-validator-expression-format.test.ts
│   │   │   ├── workflow-validator-loops-simple.test.ts
│   │   │   ├── workflow-validator-loops.test.ts
│   │   │   ├── workflow-validator-mocks.test.ts
│   │   │   ├── workflow-validator-performance.test.ts
│   │   │   ├── workflow-validator-with-mocks.test.ts
│   │   │   └── workflow-validator.test.ts
│   │   ├── telemetry
│   │   │   ├── batch-processor.test.ts
│   │   │   ├── config-manager.test.ts
│   │   │   ├── event-tracker.test.ts
│   │   │   ├── event-validator.test.ts
│   │   │   ├── rate-limiter.test.ts
│   │   │   ├── telemetry-error.test.ts
│   │   │   ├── telemetry-manager.test.ts
│   │   │   ├── v2.18.3-fixes-verification.test.ts
│   │   │   └── workflow-sanitizer.test.ts
│   │   ├── templates
│   │   │   ├── batch-processor.test.ts
│   │   │   ├── metadata-generator.test.ts
│   │   │   ├── template-repository-metadata.test.ts
│   │   │   └── template-repository-security.test.ts
│   │   ├── test-env-example.test.ts
│   │   ├── test-infrastructure.test.ts
│   │   ├── types
│   │   │   ├── instance-context-coverage.test.ts
│   │   │   └── instance-context-multi-tenant.test.ts
│   │   ├── utils
│   │   │   ├── auth-timing-safe.test.ts
│   │   │   ├── cache-utils.test.ts
│   │   │   ├── console-manager.test.ts
│   │   │   ├── database-utils.test.ts
│   │   │   ├── fixed-collection-validator.test.ts
│   │   │   ├── n8n-errors.test.ts
│   │   │   ├── node-type-normalizer.test.ts
│   │   │   ├── node-type-utils.test.ts
│   │   │   ├── node-utils.test.ts
│   │   │   ├── simple-cache-memory-leak-fix.test.ts
│   │   │   ├── ssrf-protection.test.ts
│   │   │   └── template-node-resolver.test.ts
│   │   └── validation-fixes.test.ts
│   └── utils
│       ├── assertions.ts
│       ├── builders
│       │   └── workflow.builder.ts
│       ├── data-generators.ts
│       ├── database-utils.ts
│       ├── README.md
│       └── test-helpers.ts
├── thumbnail.png
├── tsconfig.build.json
├── tsconfig.json
├── types
│   ├── mcp.d.ts
│   └── test-env.d.ts
├── verify-telemetry-fix.js
├── versioned-nodes.md
├── vitest.config.benchmark.ts
├── vitest.config.integration.ts
└── vitest.config.ts
```

# Files

--------------------------------------------------------------------------------
/tests/unit/services/task-templates.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 | import { TaskTemplates } from '@/services/task-templates';
  3 | import type { TaskTemplate } from '@/services/task-templates';
  4 | 
  5 | // Mock the database
  6 | vi.mock('better-sqlite3');
  7 | 
  8 | describe('TaskTemplates', () => {
  9 |   beforeEach(() => {
 10 |     vi.clearAllMocks();
 11 |   });
 12 | 
 13 |   describe('getTaskTemplate', () => {
 14 |     it('should return template for get_api_data task', () => {
 15 |       const template = TaskTemplates.getTaskTemplate('get_api_data');
 16 | 
 17 |       expect(template).toBeDefined();
 18 |       expect(template?.task).toBe('get_api_data');
 19 |       expect(template?.nodeType).toBe('nodes-base.httpRequest');
 20 |       expect(template?.configuration).toMatchObject({
 21 |         method: 'GET',
 22 |         retryOnFail: true,
 23 |         maxTries: 3
 24 |       });
 25 |     });
 26 | 
 27 |     it('should return template for webhook tasks', () => {
 28 |       const template = TaskTemplates.getTaskTemplate('receive_webhook');
 29 | 
 30 |       expect(template).toBeDefined();
 31 |       expect(template?.nodeType).toBe('nodes-base.webhook');
 32 |       expect(template?.configuration).toMatchObject({
 33 |         httpMethod: 'POST',
 34 |         responseMode: 'lastNode',
 35 |         alwaysOutputData: true
 36 |       });
 37 |     });
 38 | 
 39 |     it('should return template for database tasks', () => {
 40 |       const template = TaskTemplates.getTaskTemplate('query_postgres');
 41 | 
 42 |       expect(template).toBeDefined();
 43 |       expect(template?.nodeType).toBe('nodes-base.postgres');
 44 |       expect(template?.configuration).toMatchObject({
 45 |         operation: 'executeQuery',
 46 |         onError: 'continueRegularOutput'
 47 |       });
 48 |     });
 49 | 
 50 |     it('should return undefined for unknown task', () => {
 51 |       const template = TaskTemplates.getTaskTemplate('unknown_task');
 52 | 
 53 |       expect(template).toBeUndefined();
 54 |     });
 55 | 
 56 |     it('should have getTemplate alias working', () => {
 57 |       const template1 = TaskTemplates.getTaskTemplate('get_api_data');
 58 |       const template2 = TaskTemplates.getTemplate('get_api_data');
 59 | 
 60 |       expect(template1).toEqual(template2);
 61 |     });
 62 |   });
 63 | 
 64 |   describe('template structure', () => {
 65 |     it('should have all required fields in templates', () => {
 66 |       const allTasks = TaskTemplates.getAllTasks();
 67 | 
 68 |       allTasks.forEach(task => {
 69 |         const template = TaskTemplates.getTaskTemplate(task);
 70 |         
 71 |         expect(template).toBeDefined();
 72 |         expect(template?.task).toBe(task);
 73 |         expect(template?.description).toBeTruthy();
 74 |         expect(template?.nodeType).toBeTruthy();
 75 |         expect(template?.configuration).toBeDefined();
 76 |         expect(template?.userMustProvide).toBeDefined();
 77 |         expect(Array.isArray(template?.userMustProvide)).toBe(true);
 78 |       });
 79 |     });
 80 | 
 81 |     it('should have proper user must provide structure', () => {
 82 |       const template = TaskTemplates.getTaskTemplate('post_json_request');
 83 | 
 84 |       expect(template?.userMustProvide).toHaveLength(2);
 85 |       expect(template?.userMustProvide[0]).toMatchObject({
 86 |         property: 'url',
 87 |         description: expect.any(String),
 88 |         example: 'https://api.example.com/users'
 89 |       });
 90 |     });
 91 | 
 92 |     it('should have optional enhancements where applicable', () => {
 93 |       const template = TaskTemplates.getTaskTemplate('get_api_data');
 94 | 
 95 |       expect(template?.optionalEnhancements).toBeDefined();
 96 |       expect(template?.optionalEnhancements?.length).toBeGreaterThan(0);
 97 |       expect(template?.optionalEnhancements?.[0]).toHaveProperty('property');
 98 |       expect(template?.optionalEnhancements?.[0]).toHaveProperty('description');
 99 |     });
100 | 
101 |     it('should have notes for complex templates', () => {
102 |       const template = TaskTemplates.getTaskTemplate('post_json_request');
103 | 
104 |       expect(template?.notes).toBeDefined();
105 |       expect(template?.notes?.length).toBeGreaterThan(0);
106 |       expect(template?.notes?.[0]).toContain('JSON');
107 |     });
108 |   });
109 | 
110 |   describe('special templates', () => {
111 |     it('should have process_webhook_data template with detailed code', () => {
112 |       const template = TaskTemplates.getTaskTemplate('process_webhook_data');
113 | 
114 |       expect(template?.nodeType).toBe('nodes-base.code');
115 |       expect(template?.configuration.jsCode).toContain('items[0].json.body');
116 |       expect(template?.configuration.jsCode).toContain('❌ WRONG');
117 |       expect(template?.configuration.jsCode).toContain('✅ CORRECT');
118 |       expect(template?.notes?.[0]).toContain('WEBHOOK DATA IS AT items[0].json.body');
119 |     });
120 | 
121 |     it('should have AI agent workflow template', () => {
122 |       const template = TaskTemplates.getTaskTemplate('ai_agent_workflow');
123 | 
124 |       expect(template?.nodeType).toBe('nodes-langchain.agent');
125 |       expect(template?.configuration).toHaveProperty('systemMessage');
126 |     });
127 | 
128 |     it('should have error handling pattern templates', () => {
129 |       const template = TaskTemplates.getTaskTemplate('modern_error_handling_patterns');
130 | 
131 |       expect(template).toBeDefined();
132 |       expect(template?.configuration).toHaveProperty('onError', 'continueRegularOutput');
133 |       expect(template?.configuration).toHaveProperty('retryOnFail', true);
134 |       expect(template?.notes).toBeDefined();
135 |     });
136 | 
137 |     it('should have AI tool templates', () => {
138 |       const template = TaskTemplates.getTaskTemplate('custom_ai_tool');
139 | 
140 |       expect(template?.nodeType).toBe('nodes-base.code');
141 |       expect(template?.configuration.mode).toBe('runOnceForEachItem');
142 |       expect(template?.configuration.jsCode).toContain('$json');
143 |     });
144 |   });
145 | 
146 |   describe('getAllTasks', () => {
147 |     it('should return all task names', () => {
148 |       const tasks = TaskTemplates.getAllTasks();
149 | 
150 |       expect(Array.isArray(tasks)).toBe(true);
151 |       expect(tasks.length).toBeGreaterThan(20);
152 |       expect(tasks).toContain('get_api_data');
153 |       expect(tasks).toContain('receive_webhook');
154 |       expect(tasks).toContain('query_postgres');
155 |     });
156 |   });
157 | 
158 |   describe('getTasksForNode', () => {
159 |     it('should return tasks for HTTP Request node', () => {
160 |       const tasks = TaskTemplates.getTasksForNode('nodes-base.httpRequest');
161 | 
162 |       expect(tasks).toContain('get_api_data');
163 |       expect(tasks).toContain('post_json_request');
164 |       expect(tasks).toContain('call_api_with_auth');
165 |       expect(tasks).toContain('api_call_with_retry');
166 |     });
167 | 
168 |     it('should return tasks for Code node', () => {
169 |       const tasks = TaskTemplates.getTasksForNode('nodes-base.code');
170 | 
171 |       expect(tasks).toContain('transform_data');
172 |       expect(tasks).toContain('process_webhook_data');
173 |       expect(tasks).toContain('custom_ai_tool');
174 |       expect(tasks).toContain('aggregate_data');
175 |     });
176 | 
177 |     it('should return tasks for Webhook node', () => {
178 |       const tasks = TaskTemplates.getTasksForNode('nodes-base.webhook');
179 | 
180 |       expect(tasks).toContain('receive_webhook');
181 |       expect(tasks).toContain('webhook_with_response');
182 |       expect(tasks).toContain('webhook_with_error_handling');
183 |     });
184 | 
185 |     it('should return empty array for unknown node', () => {
186 |       const tasks = TaskTemplates.getTasksForNode('nodes-base.unknownNode');
187 | 
188 |       expect(tasks).toEqual([]);
189 |     });
190 |   });
191 | 
192 |   describe('searchTasks', () => {
193 |     it('should find tasks by name', () => {
194 |       const tasks = TaskTemplates.searchTasks('webhook');
195 | 
196 |       expect(tasks).toContain('receive_webhook');
197 |       expect(tasks).toContain('webhook_with_response');
198 |       expect(tasks).toContain('process_webhook_data');
199 |     });
200 | 
201 |     it('should find tasks by description', () => {
202 |       const tasks = TaskTemplates.searchTasks('resilient');
203 | 
204 |       expect(tasks.length).toBeGreaterThan(0);
205 |       expect(tasks.some(t => {
206 |         const template = TaskTemplates.getTaskTemplate(t);
207 |         return template?.description.toLowerCase().includes('resilient');
208 |       })).toBe(true);
209 |     });
210 | 
211 |     it('should find tasks by node type', () => {
212 |       const tasks = TaskTemplates.searchTasks('postgres');
213 | 
214 |       expect(tasks).toContain('query_postgres');
215 |       expect(tasks).toContain('insert_postgres_data');
216 |     });
217 | 
218 |     it('should be case insensitive', () => {
219 |       const tasks1 = TaskTemplates.searchTasks('WEBHOOK');
220 |       const tasks2 = TaskTemplates.searchTasks('webhook');
221 | 
222 |       expect(tasks1).toEqual(tasks2);
223 |     });
224 | 
225 |     it('should return empty array for no matches', () => {
226 |       const tasks = TaskTemplates.searchTasks('xyz123nonexistent');
227 | 
228 |       expect(tasks).toEqual([]);
229 |     });
230 |   });
231 | 
232 |   describe('getTaskCategories', () => {
233 |     it('should return all task categories', () => {
234 |       const categories = TaskTemplates.getTaskCategories();
235 | 
236 |       expect(Object.keys(categories)).toContain('HTTP/API');
237 |       expect(Object.keys(categories)).toContain('Webhooks');
238 |       expect(Object.keys(categories)).toContain('Database');
239 |       expect(Object.keys(categories)).toContain('AI/LangChain');
240 |       expect(Object.keys(categories)).toContain('Data Processing');
241 |       expect(Object.keys(categories)).toContain('Communication');
242 |       expect(Object.keys(categories)).toContain('Error Handling');
243 |     });
244 | 
245 |     it('should have tasks assigned to categories', () => {
246 |       const categories = TaskTemplates.getTaskCategories();
247 | 
248 |       expect(categories['HTTP/API']).toContain('get_api_data');
249 |       expect(categories['Webhooks']).toContain('receive_webhook');
250 |       expect(categories['Database']).toContain('query_postgres');
251 |       expect(categories['AI/LangChain']).toContain('chat_with_ai');
252 |     });
253 | 
254 |     it('should have tasks in multiple categories where appropriate', () => {
255 |       const categories = TaskTemplates.getTaskCategories();
256 | 
257 |       // process_webhook_data should be in both Webhooks and Data Processing
258 |       expect(categories['Webhooks']).toContain('process_webhook_data');
259 |       expect(categories['Data Processing']).toContain('process_webhook_data');
260 |     });
261 |   });
262 | 
263 |   describe('error handling templates', () => {
264 |     it('should have proper retry configuration', () => {
265 |       const template = TaskTemplates.getTaskTemplate('api_call_with_retry');
266 | 
267 |       expect(template?.configuration).toMatchObject({
268 |         retryOnFail: true,
269 |         maxTries: 5,
270 |         waitBetweenTries: 2000,
271 |         alwaysOutputData: true
272 |       });
273 |     });
274 | 
275 |     it('should have database transaction safety template', () => {
276 |       const template = TaskTemplates.getTaskTemplate('database_transaction_safety');
277 | 
278 |       expect(template?.configuration).toMatchObject({
279 |         onError: 'continueErrorOutput',
280 |         retryOnFail: false, // Transactions should not be retried
281 |         alwaysOutputData: true
282 |       });
283 |     });
284 | 
285 |     it('should have AI rate limit handling', () => {
286 |       const template = TaskTemplates.getTaskTemplate('ai_rate_limit_handling');
287 | 
288 |       expect(template?.configuration).toMatchObject({
289 |         retryOnFail: true,
290 |         maxTries: 5,
291 |         waitBetweenTries: 5000 // Longer wait for rate limits
292 |       });
293 |     });
294 |   });
295 | 
296 |   describe('code node templates', () => {
297 |     it('should have aggregate data template', () => {
298 |       const template = TaskTemplates.getTaskTemplate('aggregate_data');
299 | 
300 |       expect(template?.configuration.jsCode).toContain('stats');
301 |       expect(template?.configuration.jsCode).toContain('average');
302 |       expect(template?.configuration.jsCode).toContain('median');
303 |     });
304 | 
305 |     it('should have batch processing template', () => {
306 |       const template = TaskTemplates.getTaskTemplate('batch_process_with_api');
307 | 
308 |       expect(template?.configuration.jsCode).toContain('BATCH_SIZE');
309 |       expect(template?.configuration.jsCode).toContain('$helpers.httpRequest');
310 |     });
311 | 
312 |     it('should have error safe transform template', () => {
313 |       const template = TaskTemplates.getTaskTemplate('error_safe_transform');
314 | 
315 |       expect(template?.configuration.jsCode).toContain('required fields');
316 |       expect(template?.configuration.jsCode).toContain('validation');
317 |       expect(template?.configuration.jsCode).toContain('summary');
318 |     });
319 | 
320 |     it('should have async processing template', () => {
321 |       const template = TaskTemplates.getTaskTemplate('async_data_processing');
322 | 
323 |       expect(template?.configuration.jsCode).toContain('CONCURRENT_LIMIT');
324 |       expect(template?.configuration.jsCode).toContain('Promise.all');
325 |     });
326 | 
327 |     it('should have Python data analysis template', () => {
328 |       const template = TaskTemplates.getTaskTemplate('python_data_analysis');
329 | 
330 |       expect(template?.configuration.language).toBe('python');
331 |       expect(template?.configuration.pythonCode).toContain('_input.all()');
332 |       expect(template?.configuration.pythonCode).toContain('statistics');
333 |     });
334 |   });
335 | 
336 |   describe('template configurations', () => {
337 |     it('should have proper error handling defaults', () => {
338 |       const apiTemplate = TaskTemplates.getTaskTemplate('get_api_data');
339 |       const webhookTemplate = TaskTemplates.getTaskTemplate('receive_webhook');
340 |       const dbWriteTemplate = TaskTemplates.getTaskTemplate('insert_postgres_data');
341 | 
342 |       // API calls should continue on error
343 |       expect(apiTemplate?.configuration.onError).toBe('continueRegularOutput');
344 |       
345 |       // Webhooks should always respond
346 |       expect(webhookTemplate?.configuration.onError).toBe('continueRegularOutput');
347 |       expect(webhookTemplate?.configuration.alwaysOutputData).toBe(true);
348 |       
349 |       // Database writes should stop on error
350 |       expect(dbWriteTemplate?.configuration.onError).toBe('stopWorkflow');
351 |     });
352 | 
353 |     it('should have appropriate retry configurations', () => {
354 |       const apiTemplate = TaskTemplates.getTaskTemplate('get_api_data');
355 |       const dbTemplate = TaskTemplates.getTaskTemplate('query_postgres');
356 |       const aiTemplate = TaskTemplates.getTaskTemplate('chat_with_ai');
357 | 
358 |       // API calls: moderate retries
359 |       expect(apiTemplate?.configuration.maxTries).toBe(3);
360 |       expect(apiTemplate?.configuration.waitBetweenTries).toBe(1000);
361 | 
362 |       // Database reads: can retry
363 |       expect(dbTemplate?.configuration.retryOnFail).toBe(true);
364 | 
365 |       // AI calls: longer waits for rate limits
366 |       expect(aiTemplate?.configuration.waitBetweenTries).toBe(5000);
367 |     });
368 |   });
369 | });
```

--------------------------------------------------------------------------------
/tests/unit/scripts/fetch-templates-extraction.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  2 | import * as zlib from 'zlib';
  3 | 
  4 | /**
  5 |  * Unit tests for template configuration extraction functions
  6 |  * Testing the core logic from fetch-templates.ts
  7 |  */
  8 | 
  9 | // Extract the functions to test by importing or recreating them
 10 | function extractNodeConfigs(
 11 |   templateId: number,
 12 |   templateName: string,
 13 |   templateViews: number,
 14 |   workflowCompressed: string,
 15 |   metadata: any
 16 | ): Array<{
 17 |   node_type: string;
 18 |   template_id: number;
 19 |   template_name: string;
 20 |   template_views: number;
 21 |   node_name: string;
 22 |   parameters_json: string;
 23 |   credentials_json: string | null;
 24 |   has_credentials: number;
 25 |   has_expressions: number;
 26 |   complexity: string;
 27 |   use_cases: string;
 28 | }> {
 29 |   try {
 30 |     const decompressed = zlib.gunzipSync(Buffer.from(workflowCompressed, 'base64'));
 31 |     const workflow = JSON.parse(decompressed.toString('utf-8'));
 32 | 
 33 |     const configs: any[] = [];
 34 | 
 35 |     for (const node of workflow.nodes || []) {
 36 |       if (node.type.includes('stickyNote') || !node.parameters) {
 37 |         continue;
 38 |       }
 39 | 
 40 |       configs.push({
 41 |         node_type: node.type,
 42 |         template_id: templateId,
 43 |         template_name: templateName,
 44 |         template_views: templateViews,
 45 |         node_name: node.name,
 46 |         parameters_json: JSON.stringify(node.parameters),
 47 |         credentials_json: node.credentials ? JSON.stringify(node.credentials) : null,
 48 |         has_credentials: node.credentials ? 1 : 0,
 49 |         has_expressions: detectExpressions(node.parameters) ? 1 : 0,
 50 |         complexity: metadata?.complexity || 'medium',
 51 |         use_cases: JSON.stringify(metadata?.use_cases || [])
 52 |       });
 53 |     }
 54 | 
 55 |     return configs;
 56 |   } catch (error) {
 57 |     return [];
 58 |   }
 59 | }
 60 | 
 61 | function detectExpressions(params: any): boolean {
 62 |   if (!params) return false;
 63 |   const json = JSON.stringify(params);
 64 |   return json.includes('={{') || json.includes('$json') || json.includes('$node');
 65 | }
 66 | 
 67 | describe('Template Configuration Extraction', () => {
 68 |   describe('extractNodeConfigs', () => {
 69 |     it('should extract configs from valid workflow with multiple nodes', () => {
 70 |       const workflow = {
 71 |         nodes: [
 72 |           {
 73 |             id: 'node1',
 74 |             name: 'Webhook',
 75 |             type: 'n8n-nodes-base.webhook',
 76 |             typeVersion: 1,
 77 |             position: [100, 100],
 78 |             parameters: {
 79 |               httpMethod: 'POST',
 80 |               path: 'webhook-test'
 81 |             }
 82 |           },
 83 |           {
 84 |             id: 'node2',
 85 |             name: 'HTTP Request',
 86 |             type: 'n8n-nodes-base.httpRequest',
 87 |             typeVersion: 3,
 88 |             position: [300, 100],
 89 |             parameters: {
 90 |               url: 'https://api.example.com',
 91 |               method: 'GET'
 92 |             }
 93 |           }
 94 |         ],
 95 |         connections: {}
 96 |       };
 97 | 
 98 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
 99 |       const metadata = {
100 |         complexity: 'simple',
101 |         use_cases: ['webhook processing', 'API calls']
102 |       };
103 | 
104 |       const configs = extractNodeConfigs(1, 'Test Template', 500, compressed, metadata);
105 | 
106 |       expect(configs).toHaveLength(2);
107 |       expect(configs[0].node_type).toBe('n8n-nodes-base.webhook');
108 |       expect(configs[0].node_name).toBe('Webhook');
109 |       expect(configs[0].template_id).toBe(1);
110 |       expect(configs[0].template_name).toBe('Test Template');
111 |       expect(configs[0].template_views).toBe(500);
112 |       expect(configs[0].has_credentials).toBe(0);
113 |       expect(configs[0].complexity).toBe('simple');
114 | 
115 |       const parsedParams = JSON.parse(configs[0].parameters_json);
116 |       expect(parsedParams.httpMethod).toBe('POST');
117 |       expect(parsedParams.path).toBe('webhook-test');
118 | 
119 |       expect(configs[1].node_type).toBe('n8n-nodes-base.httpRequest');
120 |       expect(configs[1].node_name).toBe('HTTP Request');
121 |     });
122 | 
123 |     it('should return empty array for workflow with no nodes', () => {
124 |       const workflow = { nodes: [], connections: {} };
125 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
126 | 
127 |       const configs = extractNodeConfigs(1, 'Empty Template', 100, compressed, null);
128 | 
129 |       expect(configs).toHaveLength(0);
130 |     });
131 | 
132 |     it('should skip sticky note nodes', () => {
133 |       const workflow = {
134 |         nodes: [
135 |           {
136 |             id: 'sticky1',
137 |             name: 'Note',
138 |             type: 'n8n-nodes-base.stickyNote',
139 |             typeVersion: 1,
140 |             position: [100, 100],
141 |             parameters: { content: 'This is a note' }
142 |           },
143 |           {
144 |             id: 'node1',
145 |             name: 'HTTP Request',
146 |             type: 'n8n-nodes-base.httpRequest',
147 |             typeVersion: 3,
148 |             position: [300, 100],
149 |             parameters: { url: 'https://api.example.com' }
150 |           }
151 |         ],
152 |         connections: {}
153 |       };
154 | 
155 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
156 |       const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
157 | 
158 |       expect(configs).toHaveLength(1);
159 |       expect(configs[0].node_type).toBe('n8n-nodes-base.httpRequest');
160 |     });
161 | 
162 |     it('should skip nodes without parameters', () => {
163 |       const workflow = {
164 |         nodes: [
165 |           {
166 |             id: 'node1',
167 |             name: 'No Params',
168 |             type: 'n8n-nodes-base.someNode',
169 |             typeVersion: 1,
170 |             position: [100, 100]
171 |             // No parameters field
172 |           },
173 |           {
174 |             id: 'node2',
175 |             name: 'With Params',
176 |             type: 'n8n-nodes-base.httpRequest',
177 |             typeVersion: 3,
178 |             position: [300, 100],
179 |             parameters: { url: 'https://api.example.com' }
180 |           }
181 |         ],
182 |         connections: {}
183 |       };
184 | 
185 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
186 |       const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
187 | 
188 |       expect(configs).toHaveLength(1);
189 |       expect(configs[0].node_type).toBe('n8n-nodes-base.httpRequest');
190 |     });
191 | 
192 |     it('should handle nodes with credentials', () => {
193 |       const workflow = {
194 |         nodes: [
195 |           {
196 |             id: 'node1',
197 |             name: 'Slack',
198 |             type: 'n8n-nodes-base.slack',
199 |             typeVersion: 1,
200 |             position: [100, 100],
201 |             parameters: {
202 |               resource: 'message',
203 |               operation: 'post'
204 |             },
205 |             credentials: {
206 |               slackApi: {
207 |                 id: '1',
208 |                 name: 'Slack API'
209 |               }
210 |             }
211 |           }
212 |         ],
213 |         connections: {}
214 |       };
215 | 
216 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
217 |       const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
218 | 
219 |       expect(configs).toHaveLength(1);
220 |       expect(configs[0].has_credentials).toBe(1);
221 |       expect(configs[0].credentials_json).toBeTruthy();
222 | 
223 |       const creds = JSON.parse(configs[0].credentials_json!);
224 |       expect(creds.slackApi).toBeDefined();
225 |     });
226 | 
227 |     it('should use default complexity when metadata is missing', () => {
228 |       const workflow = {
229 |         nodes: [
230 |           {
231 |             id: 'node1',
232 |             name: 'HTTP Request',
233 |             type: 'n8n-nodes-base.httpRequest',
234 |             typeVersion: 3,
235 |             position: [100, 100],
236 |             parameters: { url: 'https://api.example.com' }
237 |           }
238 |         ],
239 |         connections: {}
240 |       };
241 | 
242 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
243 |       const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
244 | 
245 |       expect(configs[0].complexity).toBe('medium');
246 |       expect(configs[0].use_cases).toBe('[]');
247 |     });
248 | 
249 |     it('should handle malformed compressed data gracefully', () => {
250 |       const invalidCompressed = 'invalid-base64-data';
251 |       const configs = extractNodeConfigs(1, 'Test', 100, invalidCompressed, null);
252 | 
253 |       expect(configs).toHaveLength(0);
254 |     });
255 | 
256 |     it('should handle invalid JSON after decompression', () => {
257 |       const invalidJson = 'not valid json';
258 |       const compressed = zlib.gzipSync(Buffer.from(invalidJson)).toString('base64');
259 |       const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
260 | 
261 |       expect(configs).toHaveLength(0);
262 |     });
263 | 
264 |     it('should handle workflows with missing nodes array', () => {
265 |       const workflow = { connections: {} };
266 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
267 |       const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
268 | 
269 |       expect(configs).toHaveLength(0);
270 |     });
271 |   });
272 | 
273 |   describe('detectExpressions', () => {
274 |     it('should detect n8n expression syntax with ={{...}}', () => {
275 |       const params = {
276 |         url: '={{ $json.apiUrl }}',
277 |         method: 'GET'
278 |       };
279 | 
280 |       expect(detectExpressions(params)).toBe(true);
281 |     });
282 | 
283 |     it('should detect $json references', () => {
284 |       const params = {
285 |         body: {
286 |           data: '$json.data'
287 |         }
288 |       };
289 | 
290 |       expect(detectExpressions(params)).toBe(true);
291 |     });
292 | 
293 |     it('should detect $node references', () => {
294 |       const params = {
295 |         url: 'https://api.example.com',
296 |         headers: {
297 |           authorization: '$node["Webhook"].json.token'
298 |         }
299 |       };
300 | 
301 |       expect(detectExpressions(params)).toBe(true);
302 |     });
303 | 
304 |     it('should return false for parameters without expressions', () => {
305 |       const params = {
306 |         url: 'https://api.example.com',
307 |         method: 'POST',
308 |         body: {
309 |           name: 'test'
310 |         }
311 |       };
312 | 
313 |       expect(detectExpressions(params)).toBe(false);
314 |     });
315 | 
316 |     it('should handle nested objects with expressions', () => {
317 |       const params = {
318 |         options: {
319 |           queryParameters: {
320 |             filters: {
321 |               id: '={{ $json.userId }}'
322 |             }
323 |           }
324 |         }
325 |       };
326 | 
327 |       expect(detectExpressions(params)).toBe(true);
328 |     });
329 | 
330 |     it('should return false for null parameters', () => {
331 |       expect(detectExpressions(null)).toBe(false);
332 |     });
333 | 
334 |     it('should return false for undefined parameters', () => {
335 |       expect(detectExpressions(undefined)).toBe(false);
336 |     });
337 | 
338 |     it('should return false for empty object', () => {
339 |       expect(detectExpressions({})).toBe(false);
340 |     });
341 | 
342 |     it('should handle array parameters with expressions', () => {
343 |       const params = {
344 |         items: [
345 |           { value: '={{ $json.item1 }}' },
346 |           { value: '={{ $json.item2 }}' }
347 |         ]
348 |       };
349 | 
350 |       expect(detectExpressions(params)).toBe(true);
351 |     });
352 | 
353 |     it('should detect multiple expression types in same params', () => {
354 |       const params = {
355 |         url: '={{ $node["HTTP Request"].json.nextUrl }}',
356 |         body: {
357 |           data: '$json.data',
358 |           token: '={{ $json.token }}'
359 |         }
360 |       };
361 | 
362 |       expect(detectExpressions(params)).toBe(true);
363 |     });
364 |   });
365 | 
366 |   describe('Edge Cases', () => {
367 |     it('should handle very large workflows without crashing', () => {
368 |       const nodes = Array.from({ length: 100 }, (_, i) => ({
369 |         id: `node${i}`,
370 |         name: `Node ${i}`,
371 |         type: 'n8n-nodes-base.httpRequest',
372 |         typeVersion: 3,
373 |         position: [100 * i, 100],
374 |         parameters: {
375 |           url: `https://api.example.com/${i}`,
376 |           method: 'GET'
377 |         }
378 |       }));
379 | 
380 |       const workflow = { nodes, connections: {} };
381 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
382 |       const configs = extractNodeConfigs(1, 'Large Template', 1000, compressed, null);
383 | 
384 |       expect(configs).toHaveLength(100);
385 |     });
386 | 
387 |     it('should handle special characters in node names and parameters', () => {
388 |       const workflow = {
389 |         nodes: [
390 |           {
391 |             id: 'node1',
392 |             name: 'Node with 特殊文字 & émojis 🎉',
393 |             type: 'n8n-nodes-base.httpRequest',
394 |             typeVersion: 3,
395 |             position: [100, 100],
396 |             parameters: {
397 |               url: 'https://api.example.com?query=test&special=值',
398 |               headers: {
399 |                 'X-Custom-Header': 'value with spaces & symbols!@#$%'
400 |               }
401 |             }
402 |           }
403 |         ],
404 |         connections: {}
405 |       };
406 | 
407 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
408 |       const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
409 | 
410 |       expect(configs).toHaveLength(1);
411 |       expect(configs[0].node_name).toBe('Node with 特殊文字 & émojis 🎉');
412 | 
413 |       const params = JSON.parse(configs[0].parameters_json);
414 |       expect(params.headers['X-Custom-Header']).toBe('value with spaces & symbols!@#$%');
415 |     });
416 | 
417 |     it('should preserve parameter structure exactly as in workflow', () => {
418 |       const workflow = {
419 |         nodes: [
420 |           {
421 |             id: 'node1',
422 |             name: 'Complex Node',
423 |             type: 'n8n-nodes-base.httpRequest',
424 |             typeVersion: 3,
425 |             position: [100, 100],
426 |             parameters: {
427 |               url: 'https://api.example.com',
428 |               options: {
429 |                 queryParameters: {
430 |                   filters: [
431 |                     { name: 'status', value: 'active' },
432 |                     { name: 'type', value: 'user' }
433 |                   ]
434 |                 },
435 |                 timeout: 10000,
436 |                 redirect: {
437 |                   followRedirects: true,
438 |                   maxRedirects: 5
439 |                 }
440 |               }
441 |             }
442 |           }
443 |         ],
444 |         connections: {}
445 |       };
446 | 
447 |       const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
448 |       const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
449 | 
450 |       const params = JSON.parse(configs[0].parameters_json);
451 |       expect(params.options.queryParameters.filters).toHaveLength(2);
452 |       expect(params.options.timeout).toBe(10000);
453 |       expect(params.options.redirect.maxRedirects).toBe(5);
454 |     });
455 |   });
456 | });
457 | 
```

--------------------------------------------------------------------------------
/tests/integration/n8n-api/workflows/list-workflows.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Integration Tests: handleListWorkflows
  3 |  *
  4 |  * Tests workflow listing against a real n8n instance.
  5 |  * Covers filtering, pagination, and various list parameters.
  6 |  */
  7 | 
  8 | import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
  9 | import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
 10 | import { getTestN8nClient } from '../utils/n8n-client';
 11 | import { N8nApiClient } from '../../../../src/services/n8n-api-client';
 12 | import { SIMPLE_WEBHOOK_WORKFLOW, SIMPLE_HTTP_WORKFLOW } from '../utils/fixtures';
 13 | import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
 14 | import { createMcpContext } from '../utils/mcp-context';
 15 | import { InstanceContext } from '../../../../src/types/instance-context';
 16 | import { handleListWorkflows } from '../../../../src/mcp/handlers-n8n-manager';
 17 | 
 18 | describe('Integration: handleListWorkflows', () => {
 19 |   let context: TestContext;
 20 |   let client: N8nApiClient;
 21 |   let mcpContext: InstanceContext;
 22 | 
 23 |   beforeEach(() => {
 24 |     context = createTestContext();
 25 |     client = getTestN8nClient();
 26 |     mcpContext = createMcpContext();
 27 |   });
 28 | 
 29 |   afterEach(async () => {
 30 |     await context.cleanup();
 31 |   });
 32 | 
 33 |   afterAll(async () => {
 34 |     if (!process.env.CI) {
 35 |       await cleanupOrphanedWorkflows();
 36 |     }
 37 |   });
 38 | 
 39 |   // ======================================================================
 40 |   // No Filters
 41 |   // ======================================================================
 42 | 
 43 |   describe('No Filters', () => {
 44 |     it('should list all workflows without filters', async () => {
 45 |       // Create test workflows
 46 |       const workflow1 = {
 47 |         ...SIMPLE_WEBHOOK_WORKFLOW,
 48 |         name: createTestWorkflowName('List - All 1'),
 49 |         tags: ['mcp-integration-test']
 50 |       };
 51 | 
 52 |       const workflow2 = {
 53 |         ...SIMPLE_HTTP_WORKFLOW,
 54 |         name: createTestWorkflowName('List - All 2'),
 55 |         tags: ['mcp-integration-test']
 56 |       };
 57 | 
 58 |       const created1 = await client.createWorkflow(workflow1);
 59 |       const created2 = await client.createWorkflow(workflow2);
 60 |       context.trackWorkflow(created1.id!);
 61 |       context.trackWorkflow(created2.id!);
 62 | 
 63 |       // List workflows without filters
 64 |       const response = await handleListWorkflows({}, mcpContext);
 65 | 
 66 |       expect(response.success).toBe(true);
 67 |       expect(response.data).toBeDefined();
 68 | 
 69 |       const data = response.data as any;
 70 |       expect(Array.isArray(data.workflows)).toBe(true);
 71 |       expect(data.workflows.length).toBeGreaterThan(0);
 72 | 
 73 |       // Our workflows should be in the list
 74 |       const workflow1Found = data.workflows.find((w: any) => w.id === created1.id);
 75 |       const workflow2Found = data.workflows.find((w: any) => w.id === created2.id);
 76 |       expect(workflow1Found).toBeDefined();
 77 |       expect(workflow2Found).toBeDefined();
 78 |     });
 79 |   });
 80 | 
 81 |   // ======================================================================
 82 |   // Filter by Active Status
 83 |   // ======================================================================
 84 | 
 85 |   describe('Filter by Active Status', () => {
 86 |     it('should filter workflows by active=true', async () => {
 87 |       // Create active workflow
 88 |       const activeWorkflow = {
 89 |         ...SIMPLE_WEBHOOK_WORKFLOW,
 90 |         name: createTestWorkflowName('List - Active'),
 91 |         active: true,
 92 |         tags: ['mcp-integration-test']
 93 |       };
 94 | 
 95 |       const created = await client.createWorkflow(activeWorkflow);
 96 |       context.trackWorkflow(created.id!);
 97 | 
 98 |       // Activate workflow
 99 |       await client.updateWorkflow(created.id!, {
100 |         ...activeWorkflow,
101 |         active: true
102 |       });
103 | 
104 |       // List active workflows
105 |       const response = await handleListWorkflows(
106 |         { active: true },
107 |         mcpContext
108 |       );
109 | 
110 |       expect(response.success).toBe(true);
111 |       const data = response.data as any;
112 | 
113 |       // All returned workflows should be active
114 |       data.workflows.forEach((w: any) => {
115 |         expect(w.active).toBe(true);
116 |       });
117 |     });
118 | 
119 |     it('should filter workflows by active=false', async () => {
120 |       // Create inactive workflow
121 |       const inactiveWorkflow = {
122 |         ...SIMPLE_WEBHOOK_WORKFLOW,
123 |         name: createTestWorkflowName('List - Inactive'),
124 |         active: false,
125 |         tags: ['mcp-integration-test']
126 |       };
127 | 
128 |       const created = await client.createWorkflow(inactiveWorkflow);
129 |       context.trackWorkflow(created.id!);
130 | 
131 |       // List inactive workflows
132 |       const response = await handleListWorkflows(
133 |         { active: false },
134 |         mcpContext
135 |       );
136 | 
137 |       expect(response.success).toBe(true);
138 |       const data = response.data as any;
139 | 
140 |       // All returned workflows should be inactive
141 |       data.workflows.forEach((w: any) => {
142 |         expect(w.active).toBe(false);
143 |       });
144 | 
145 |       // Our workflow should be in the list
146 |       const found = data.workflows.find((w: any) => w.id === created.id);
147 |       expect(found).toBeDefined();
148 |     });
149 |   });
150 | 
151 |   // ======================================================================
152 |   // Filter by Tags
153 |   // ======================================================================
154 | 
155 |   describe('Filter by Tags', () => {
156 |     it('should filter workflows by name instead of tags', async () => {
157 |       // Note: Tags filtering requires tag IDs, not names, and tags are readonly in workflow creation
158 |       // This test filters by name instead, which is more reliable for integration testing
159 |       const uniqueName = createTestWorkflowName('List - Name Filter Test');
160 |       const workflow = {
161 |         ...SIMPLE_WEBHOOK_WORKFLOW,
162 |         name: uniqueName,
163 |         tags: ['mcp-integration-test']
164 |       };
165 | 
166 |       const created = await client.createWorkflow(workflow);
167 |       context.trackWorkflow(created.id!);
168 | 
169 |       // List all workflows and verify ours is included
170 |       const response = await handleListWorkflows({}, mcpContext);
171 | 
172 |       expect(response.success).toBe(true);
173 |       const data = response.data as any;
174 | 
175 |       // Our workflow should be in the list
176 |       const found = data.workflows.find((w: any) => w.id === created.id);
177 |       expect(found).toBeDefined();
178 |       expect(found.name).toBe(uniqueName);
179 |     });
180 |   });
181 | 
182 |   // ======================================================================
183 |   // Pagination
184 |   // ======================================================================
185 | 
186 |   describe('Pagination', () => {
187 |     it('should return first page with limit', async () => {
188 |       // Create multiple workflows
189 |       const workflows = [];
190 |       for (let i = 0; i < 3; i++) {
191 |         const workflow = {
192 |           ...SIMPLE_WEBHOOK_WORKFLOW,
193 |           name: createTestWorkflowName(`List - Page ${i}`),
194 |           tags: ['mcp-integration-test']
195 |         };
196 |         const created = await client.createWorkflow(workflow);
197 |         context.trackWorkflow(created.id!);
198 |         workflows.push(created);
199 |       }
200 | 
201 |       // List first page with limit
202 |       const response = await handleListWorkflows(
203 |         { limit: 2 },
204 |         mcpContext
205 |       );
206 | 
207 |       expect(response.success).toBe(true);
208 |       const data = response.data as any;
209 | 
210 |       expect(data.workflows.length).toBeLessThanOrEqual(2);
211 |       expect(data.hasMore).toBeDefined();
212 |       expect(data.nextCursor).toBeDefined();
213 |     });
214 | 
215 |     it('should handle pagination with cursor', async () => {
216 |       // Create multiple workflows
217 |       for (let i = 0; i < 5; i++) {
218 |         const workflow = {
219 |           ...SIMPLE_WEBHOOK_WORKFLOW,
220 |           name: createTestWorkflowName(`List - Cursor ${i}`),
221 |           tags: ['mcp-integration-test']
222 |         };
223 |         const created = await client.createWorkflow(workflow);
224 |         context.trackWorkflow(created.id!);
225 |       }
226 | 
227 |       // Get first page
228 |       const firstPage = await handleListWorkflows(
229 |         { limit: 2 },
230 |         mcpContext
231 |       );
232 | 
233 |       expect(firstPage.success).toBe(true);
234 |       const firstData = firstPage.data as any;
235 | 
236 |       if (firstData.hasMore && firstData.nextCursor) {
237 |         // Get second page using cursor
238 |         const secondPage = await handleListWorkflows(
239 |           { limit: 2, cursor: firstData.nextCursor },
240 |           mcpContext
241 |         );
242 | 
243 |         expect(secondPage.success).toBe(true);
244 |         const secondData = secondPage.data as any;
245 | 
246 |         // Second page should have different workflows
247 |         const firstIds = new Set(firstData.workflows.map((w: any) => w.id));
248 |         const secondIds = secondData.workflows.map((w: any) => w.id);
249 | 
250 |         secondIds.forEach((id: string) => {
251 |           expect(firstIds.has(id)).toBe(false);
252 |         });
253 |       }
254 |     });
255 | 
256 |     it('should handle last page (no more results)', async () => {
257 |       // Create single workflow
258 |       const workflow = {
259 |         ...SIMPLE_WEBHOOK_WORKFLOW,
260 |         name: createTestWorkflowName('List - Last Page'),
261 |         tags: ['mcp-integration-test', 'unique-last-page-tag']
262 |       };
263 | 
264 |       const created = await client.createWorkflow(workflow);
265 |       context.trackWorkflow(created.id!);
266 | 
267 |       // List with high limit and unique tag
268 |       const response = await handleListWorkflows(
269 |         {
270 |           tags: ['unique-last-page-tag'],
271 |           limit: 100
272 |         },
273 |         mcpContext
274 |       );
275 | 
276 |       expect(response.success).toBe(true);
277 |       const data = response.data as any;
278 | 
279 |       // Should not have more results
280 |       expect(data.hasMore).toBe(false);
281 |       expect(data.workflows.length).toBeLessThanOrEqual(100);
282 |     });
283 |   });
284 | 
285 |   // ======================================================================
286 |   // Limit Variations
287 |   // ======================================================================
288 | 
289 |   describe('Limit Variations', () => {
290 |     it('should respect limit=1', async () => {
291 |       // Create workflow
292 |       const workflow = {
293 |         ...SIMPLE_WEBHOOK_WORKFLOW,
294 |         name: createTestWorkflowName('List - Limit 1'),
295 |         tags: ['mcp-integration-test']
296 |       };
297 | 
298 |       const created = await client.createWorkflow(workflow);
299 |       context.trackWorkflow(created.id!);
300 | 
301 |       // List with limit=1
302 |       const response = await handleListWorkflows(
303 |         { limit: 1 },
304 |         mcpContext
305 |       );
306 | 
307 |       expect(response.success).toBe(true);
308 |       const data = response.data as any;
309 | 
310 |       expect(data.workflows.length).toBe(1);
311 |     });
312 | 
313 |     it('should respect limit=50', async () => {
314 |       // List with limit=50
315 |       const response = await handleListWorkflows(
316 |         { limit: 50 },
317 |         mcpContext
318 |       );
319 | 
320 |       expect(response.success).toBe(true);
321 |       const data = response.data as any;
322 | 
323 |       expect(data.workflows.length).toBeLessThanOrEqual(50);
324 |     });
325 | 
326 |     it('should respect limit=100 (max)', async () => {
327 |       // List with limit=100
328 |       const response = await handleListWorkflows(
329 |         { limit: 100 },
330 |         mcpContext
331 |       );
332 | 
333 |       expect(response.success).toBe(true);
334 |       const data = response.data as any;
335 | 
336 |       expect(data.workflows.length).toBeLessThanOrEqual(100);
337 |     });
338 |   });
339 | 
340 |   // ======================================================================
341 |   // Exclude Pinned Data
342 |   // ======================================================================
343 | 
344 |   describe('Exclude Pinned Data', () => {
345 |     it('should exclude pinned data when requested', async () => {
346 |       // Create workflow
347 |       const workflow = {
348 |         ...SIMPLE_WEBHOOK_WORKFLOW,
349 |         name: createTestWorkflowName('List - No Pinned Data'),
350 |         tags: ['mcp-integration-test']
351 |       };
352 | 
353 |       const created = await client.createWorkflow(workflow);
354 |       context.trackWorkflow(created.id!);
355 | 
356 |       // List with excludePinnedData=true
357 |       const response = await handleListWorkflows(
358 |         { excludePinnedData: true },
359 |         mcpContext
360 |       );
361 | 
362 |       expect(response.success).toBe(true);
363 |       const data = response.data as any;
364 | 
365 |       // Verify response doesn't include pinned data
366 |       data.workflows.forEach((w: any) => {
367 |         expect(w.pinData).toBeUndefined();
368 |       });
369 |     });
370 |   });
371 | 
372 |   // ======================================================================
373 |   // Empty Results
374 |   // ======================================================================
375 | 
376 |   describe('Empty Results', () => {
377 |     it('should return empty array when no workflows match filters', async () => {
378 |       // List with non-existent tag
379 |       const response = await handleListWorkflows(
380 |         { tags: ['non-existent-tag-xyz-12345'] },
381 |         mcpContext
382 |       );
383 | 
384 |       expect(response.success).toBe(true);
385 |       const data = response.data as any;
386 | 
387 |       expect(Array.isArray(data.workflows)).toBe(true);
388 |       expect(data.workflows.length).toBe(0);
389 |       expect(data.hasMore).toBe(false);
390 |     });
391 |   });
392 | 
393 |   // ======================================================================
394 |   // Sort Order Verification
395 |   // ======================================================================
396 | 
397 |   describe('Sort Order', () => {
398 |     it('should return workflows in consistent order', async () => {
399 |       // Create multiple workflows
400 |       for (let i = 0; i < 3; i++) {
401 |         const workflow = {
402 |           ...SIMPLE_WEBHOOK_WORKFLOW,
403 |           name: createTestWorkflowName(`List - Sort ${i}`),
404 |           tags: ['mcp-integration-test', 'sort-test']
405 |         };
406 |         const created = await client.createWorkflow(workflow);
407 |         context.trackWorkflow(created.id!);
408 |         // Small delay to ensure different timestamps
409 |         await new Promise(resolve => setTimeout(resolve, 100));
410 |       }
411 | 
412 |       // List workflows twice
413 |       const response1 = await handleListWorkflows(
414 |         { tags: ['sort-test'] },
415 |         mcpContext
416 |       );
417 | 
418 |       const response2 = await handleListWorkflows(
419 |         { tags: ['sort-test'] },
420 |         mcpContext
421 |       );
422 | 
423 |       expect(response1.success).toBe(true);
424 |       expect(response2.success).toBe(true);
425 | 
426 |       const data1 = response1.data as any;
427 |       const data2 = response2.data as any;
428 | 
429 |       // Same workflows should be returned in same order
430 |       expect(data1.workflows.length).toBe(data2.workflows.length);
431 | 
432 |       const ids1 = data1.workflows.map((w: any) => w.id);
433 |       const ids2 = data2.workflows.map((w: any) => w.id);
434 | 
435 |       expect(ids1).toEqual(ids2);
436 |     });
437 |   });
438 | });
439 | 
```

--------------------------------------------------------------------------------
/src/parsers/node-parser.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { PropertyExtractor } from './property-extractor';
  2 | import type {
  3 |   NodeClass,
  4 |   VersionedNodeInstance
  5 | } from '../types/node-types';
  6 | import {
  7 |   isVersionedNodeInstance,
  8 |   isVersionedNodeClass,
  9 |   getNodeDescription as getNodeDescriptionHelper
 10 | } from '../types/node-types';
 11 | import type { INodeTypeBaseDescription, INodeTypeDescription } from 'n8n-workflow';
 12 | 
 13 | export interface ParsedNode {
 14 |   style: 'declarative' | 'programmatic';
 15 |   nodeType: string;
 16 |   displayName: string;
 17 |   description?: string;
 18 |   category?: string;
 19 |   properties: any[];
 20 |   credentials: any[];
 21 |   isAITool: boolean;
 22 |   isTrigger: boolean;
 23 |   isWebhook: boolean;
 24 |   operations: any[];
 25 |   version?: string;
 26 |   isVersioned: boolean;
 27 |   packageName: string;
 28 |   documentation?: string;
 29 |   outputs?: any[];
 30 |   outputNames?: string[];
 31 | }
 32 | 
 33 | export class NodeParser {
 34 |   private propertyExtractor = new PropertyExtractor();
 35 |   private currentNodeClass: NodeClass | null = null;
 36 | 
 37 |   parse(nodeClass: NodeClass, packageName: string): ParsedNode {
 38 |     this.currentNodeClass = nodeClass;
 39 |     // Get base description (handles versioned nodes)
 40 |     const description = this.getNodeDescription(nodeClass);
 41 |     const outputInfo = this.extractOutputs(description);
 42 |     
 43 |     return {
 44 |       style: this.detectStyle(nodeClass),
 45 |       nodeType: this.extractNodeType(description, packageName),
 46 |       displayName: description.displayName || description.name,
 47 |       description: description.description,
 48 |       category: this.extractCategory(description),
 49 |       properties: this.propertyExtractor.extractProperties(nodeClass),
 50 |       credentials: this.propertyExtractor.extractCredentials(nodeClass),
 51 |       isAITool: this.propertyExtractor.detectAIToolCapability(nodeClass),
 52 |       isTrigger: this.detectTrigger(description),
 53 |       isWebhook: this.detectWebhook(description),
 54 |       operations: this.propertyExtractor.extractOperations(nodeClass),
 55 |       version: this.extractVersion(nodeClass),
 56 |       isVersioned: this.detectVersioned(nodeClass),
 57 |       packageName: packageName,
 58 |       outputs: outputInfo.outputs,
 59 |       outputNames: outputInfo.outputNames
 60 |     };
 61 |   }
 62 |   
 63 |   private getNodeDescription(nodeClass: NodeClass): INodeTypeBaseDescription | INodeTypeDescription {
 64 |     // Try to get description from the class first
 65 |     let description: INodeTypeBaseDescription | INodeTypeDescription | undefined;
 66 | 
 67 |     // Check if it's a versioned node using type guard
 68 |     if (isVersionedNodeClass(nodeClass)) {
 69 |       // This is a VersionedNodeType class - instantiate it
 70 |       try {
 71 |         const instance = new (nodeClass as new () => VersionedNodeInstance)();
 72 |         // Strategic any assertion for accessing both description and baseDescription
 73 |         const inst = instance as any;
 74 |         // Try description first (real VersionedNodeType with getter)
 75 |         // Only fallback to baseDescription if nodeVersions exists (complete VersionedNodeType mock)
 76 |         // This prevents using baseDescription for incomplete mocks that test edge cases
 77 |         description = inst.description || (inst.nodeVersions ? inst.baseDescription : undefined);
 78 | 
 79 |         // If still undefined (incomplete mock), leave as undefined to use catch block fallback
 80 |       } catch (e) {
 81 |         // Some nodes might require parameters to instantiate
 82 |       }
 83 |     } else if (typeof nodeClass === 'function') {
 84 |       // Try to instantiate to get description
 85 |       try {
 86 |         const instance = new nodeClass();
 87 |         description = instance.description;
 88 |         // If description is empty or missing name, check for baseDescription fallback
 89 |         if (!description || !description.name) {
 90 |           const inst = instance as any;
 91 |           if (inst.baseDescription?.name) {
 92 |             description = inst.baseDescription;
 93 |           }
 94 |         }
 95 |       } catch (e) {
 96 |         // Some nodes might require parameters to instantiate
 97 |         // Try to access static properties
 98 |         description = (nodeClass as any).description;
 99 |       }
100 |     } else {
101 |       // Maybe it's already an instance
102 |       description = nodeClass.description;
103 |       // If description is empty or missing name, check for baseDescription fallback
104 |       if (!description || !description.name) {
105 |         const inst = nodeClass as any;
106 |         if (inst.baseDescription?.name) {
107 |           description = inst.baseDescription;
108 |         }
109 |       }
110 |     }
111 | 
112 |     return description || ({} as any);
113 |   }
114 |   
115 |   private detectStyle(nodeClass: NodeClass): 'declarative' | 'programmatic' {
116 |     const desc = this.getNodeDescription(nodeClass);
117 |     return (desc as any).routing ? 'declarative' : 'programmatic';
118 |   }
119 | 
120 |   private extractNodeType(description: INodeTypeBaseDescription | INodeTypeDescription, packageName: string): string {
121 |     // Ensure we have the full node type including package prefix
122 |     const name = description.name;
123 |     
124 |     if (!name) {
125 |       throw new Error('Node is missing name property');
126 |     }
127 |     
128 |     if (name.includes('.')) {
129 |       return name;
130 |     }
131 |     
132 |     // Add package prefix if missing
133 |     const packagePrefix = packageName.replace('@n8n/', '').replace('n8n-', '');
134 |     return `${packagePrefix}.${name}`;
135 |   }
136 |   
137 |   private extractCategory(description: INodeTypeBaseDescription | INodeTypeDescription): string {
138 |     return description.group?.[0] ||
139 |            (description as any).categories?.[0] ||
140 |            (description as any).category ||
141 |            'misc';
142 |   }
143 | 
144 |   private detectTrigger(description: INodeTypeBaseDescription | INodeTypeDescription): boolean {
145 |     // Strategic any assertion for properties that only exist on INodeTypeDescription
146 |     const desc = description as any;
147 | 
148 |     // Primary check: group includes 'trigger'
149 |     if (description.group && Array.isArray(description.group)) {
150 |       if (description.group.includes('trigger')) {
151 |         return true;
152 |       }
153 |     }
154 | 
155 |     // Fallback checks for edge cases
156 |     return desc.polling === true ||
157 |            desc.trigger === true ||
158 |            desc.eventTrigger === true ||
159 |            description.name?.toLowerCase().includes('trigger');
160 |   }
161 |   
162 |   private detectWebhook(description: INodeTypeBaseDescription | INodeTypeDescription): boolean {
163 |     const desc = description as any; // INodeTypeDescription has webhooks, but INodeTypeBaseDescription doesn't
164 |     return (desc.webhooks?.length > 0) ||
165 |            desc.webhook === true ||
166 |            description.name?.toLowerCase().includes('webhook');
167 |   }
168 |   
169 |   /**
170 |    * Extracts the version from a node class.
171 |    *
172 |    * Priority Chain:
173 |    * 1. Instance currentVersion (VersionedNodeType's computed property)
174 |    * 2. Instance description.defaultVersion (explicit default)
175 |    * 3. Instance nodeVersions (fallback to max available version)
176 |    * 4. Description version array (legacy nodes)
177 |    * 5. Description version scalar (simple versioning)
178 |    * 6. Class-level properties (if instantiation fails)
179 |    * 7. Default to "1"
180 |    *
181 |    * Critical Fix (v2.17.4): Removed check for non-existent instance.baseDescription.defaultVersion
182 |    * which caused AI Agent to incorrectly return version "3" instead of "2.2"
183 |    *
184 |    * @param nodeClass - The node class or instance to extract version from
185 |    * @returns The version as a string
186 |    */
187 |   private extractVersion(nodeClass: NodeClass): string {
188 |     // Check instance properties first
189 |     try {
190 |       const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
191 |       // Strategic any assertion - instance could be INodeType or IVersionedNodeType
192 |       const inst = instance as any;
193 | 
194 |       // PRIORITY 1: Check currentVersion (what VersionedNodeType actually uses)
195 |       // For VersionedNodeType, currentVersion = defaultVersion ?? max(nodeVersions)
196 |       if (inst?.currentVersion !== undefined) {
197 |         return inst.currentVersion.toString();
198 |       }
199 | 
200 |       // PRIORITY 2: Handle instance-level description.defaultVersion
201 |       // VersionedNodeType stores baseDescription as 'description', not 'baseDescription'
202 |       if (inst?.description?.defaultVersion) {
203 |         return inst.description.defaultVersion.toString();
204 |       }
205 | 
206 |       // PRIORITY 3: Handle instance-level nodeVersions (fallback to max)
207 |       if (inst?.nodeVersions) {
208 |         const versions = Object.keys(inst.nodeVersions).map(Number);
209 |         if (versions.length > 0) {
210 |           const maxVersion = Math.max(...versions);
211 |           if (!isNaN(maxVersion)) {
212 |             return maxVersion.toString();
213 |           }
214 |         }
215 |       }
216 | 
217 |       // Handle version array in description (e.g., [1, 1.1, 1.2])
218 |       if (inst?.description?.version) {
219 |         const version = inst.description.version;
220 |         if (Array.isArray(version)) {
221 |           const numericVersions = version.map((v: any) => parseFloat(v.toString()));
222 |           if (numericVersions.length > 0) {
223 |             const maxVersion = Math.max(...numericVersions);
224 |             if (!isNaN(maxVersion)) {
225 |               return maxVersion.toString();
226 |             }
227 |           }
228 |         } else if (typeof version === 'number' || typeof version === 'string') {
229 |           return version.toString();
230 |         }
231 |       }
232 |     } catch (e) {
233 |       // Some nodes might require parameters to instantiate
234 |       // Try class-level properties
235 |     }
236 | 
237 |     // Handle class-level VersionedNodeType with defaultVersion
238 |     // Note: Most VersionedNodeType classes don't have static properties
239 |     // Strategic any assertion for class-level property access
240 |     const nodeClassAny = nodeClass as any;
241 |     if (nodeClassAny.description?.defaultVersion) {
242 |       return nodeClassAny.description.defaultVersion.toString();
243 |     }
244 | 
245 |     // Handle class-level VersionedNodeType with nodeVersions
246 |     if (nodeClassAny.nodeVersions) {
247 |       const versions = Object.keys(nodeClassAny.nodeVersions).map(Number);
248 |       if (versions.length > 0) {
249 |         const maxVersion = Math.max(...versions);
250 |         if (!isNaN(maxVersion)) {
251 |           return maxVersion.toString();
252 |         }
253 |       }
254 |     }
255 | 
256 |     // Also check class-level description for version array
257 |     const description = this.getNodeDescription(nodeClass);
258 |     const desc = description as any; // Strategic assertion for version property
259 |     if (desc?.version) {
260 |       if (Array.isArray(desc.version)) {
261 |         const numericVersions = desc.version.map((v: any) => parseFloat(v.toString()));
262 |         if (numericVersions.length > 0) {
263 |           const maxVersion = Math.max(...numericVersions);
264 |           if (!isNaN(maxVersion)) {
265 |             return maxVersion.toString();
266 |           }
267 |         }
268 |       } else if (typeof desc.version === 'number' || typeof desc.version === 'string') {
269 |         return desc.version.toString();
270 |       }
271 |     }
272 | 
273 |     // Default to version 1
274 |     return '1';
275 |   }
276 |   
277 |   private detectVersioned(nodeClass: NodeClass): boolean {
278 |     // Check instance-level properties first
279 |     try {
280 |       const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
281 |       // Strategic any assertion - instance could be INodeType or IVersionedNodeType
282 |       const inst = instance as any;
283 | 
284 |       // Check for instance baseDescription with defaultVersion
285 |       if (inst?.baseDescription?.defaultVersion) {
286 |         return true;
287 |       }
288 | 
289 |       // Check for nodeVersions
290 |       if (inst?.nodeVersions) {
291 |         return true;
292 |       }
293 | 
294 |       // Check for version array in description
295 |       if (inst?.description?.version && Array.isArray(inst.description.version)) {
296 |         return true;
297 |       }
298 |     } catch (e) {
299 |       // Some nodes might require parameters to instantiate
300 |       // Try class-level checks
301 |     }
302 | 
303 |     // Check class-level nodeVersions
304 |     // Strategic any assertion for class-level property access
305 |     const nodeClassAny = nodeClass as any;
306 |     if (nodeClassAny.nodeVersions || nodeClassAny.baseDescription?.defaultVersion) {
307 |       return true;
308 |     }
309 | 
310 |     // Also check class-level description for version array
311 |     const description = this.getNodeDescription(nodeClass);
312 |     const desc = description as any; // Strategic assertion for version property
313 |     if (desc?.version && Array.isArray(desc.version)) {
314 |       return true;
315 |     }
316 |     
317 |     return false;
318 |   }
319 | 
320 |   private extractOutputs(description: INodeTypeBaseDescription | INodeTypeDescription): { outputs?: any[], outputNames?: string[] } {
321 |     const result: { outputs?: any[], outputNames?: string[] } = {};
322 |     // Strategic any assertion for outputs/outputNames properties
323 |     const desc = description as any;
324 | 
325 |     // First check the base description
326 |     if (desc.outputs) {
327 |       result.outputs = Array.isArray(desc.outputs) ? desc.outputs : [desc.outputs];
328 |     }
329 | 
330 |     if (desc.outputNames) {
331 |       result.outputNames = Array.isArray(desc.outputNames) ? desc.outputNames : [desc.outputNames];
332 |     }
333 | 
334 |     // If no outputs found and this is a versioned node, check the latest version
335 |     if (!result.outputs && !result.outputNames) {
336 |       const nodeClass = this.currentNodeClass; // We'll need to track this
337 |       if (nodeClass) {
338 |         try {
339 |           const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
340 |           // Strategic any assertion for instance properties
341 |           const inst = instance as any;
342 |           if (inst.nodeVersions) {
343 |             // Get the latest version
344 |             const versions = Object.keys(inst.nodeVersions).map(Number);
345 |             if (versions.length > 0) {
346 |               const latestVersion = Math.max(...versions);
347 |               if (!isNaN(latestVersion)) {
348 |                 const versionedDescription = inst.nodeVersions[latestVersion]?.description;
349 |             
350 |             if (versionedDescription) {
351 |               if (versionedDescription.outputs) {
352 |                 result.outputs = Array.isArray(versionedDescription.outputs) 
353 |                   ? versionedDescription.outputs 
354 |                   : [versionedDescription.outputs];
355 |               }
356 |               
357 |               if (versionedDescription.outputNames) {
358 |                 result.outputNames = Array.isArray(versionedDescription.outputNames)
359 |                   ? versionedDescription.outputNames
360 |                   : [versionedDescription.outputNames];
361 |               }
362 |             }
363 |               }
364 |             }
365 |           }
366 |         } catch (e) {
367 |           // Ignore errors from instantiating node
368 |         }
369 |       }
370 |     }
371 |     
372 |     return result;
373 |   }
374 | }
```

--------------------------------------------------------------------------------
/tests/unit/services/property-filter.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 | import { PropertyFilter } from '@/services/property-filter';
  3 | import type { SimplifiedProperty, FilteredProperties } from '@/services/property-filter';
  4 | 
  5 | // Mock the database
  6 | vi.mock('better-sqlite3');
  7 | 
  8 | describe('PropertyFilter', () => {
  9 |   beforeEach(() => {
 10 |     vi.clearAllMocks();
 11 |   });
 12 | 
 13 |   describe('deduplicateProperties', () => {
 14 |     it('should remove duplicate properties with same name and conditions', () => {
 15 |       const properties = [
 16 |         { name: 'url', type: 'string', displayOptions: { show: { method: ['GET'] } } },
 17 |         { name: 'url', type: 'string', displayOptions: { show: { method: ['GET'] } } }, // Duplicate
 18 |         { name: 'url', type: 'string', displayOptions: { show: { method: ['POST'] } } }, // Different condition
 19 |       ];
 20 | 
 21 |       const result = PropertyFilter.deduplicateProperties(properties);
 22 | 
 23 |       expect(result).toHaveLength(2);
 24 |       expect(result[0].name).toBe('url');
 25 |       expect(result[1].name).toBe('url');
 26 |       expect(result[0].displayOptions).not.toEqual(result[1].displayOptions);
 27 |     });
 28 | 
 29 |     it('should handle properties without displayOptions', () => {
 30 |       const properties = [
 31 |         { name: 'timeout', type: 'number' },
 32 |         { name: 'timeout', type: 'number' }, // Duplicate
 33 |         { name: 'retries', type: 'number' },
 34 |       ];
 35 | 
 36 |       const result = PropertyFilter.deduplicateProperties(properties);
 37 | 
 38 |       expect(result).toHaveLength(2);
 39 |       expect(result.map(p => p.name)).toEqual(['timeout', 'retries']);
 40 |     });
 41 |   });
 42 | 
 43 |   describe('getEssentials', () => {
 44 |     it('should return configured essentials for HTTP Request node', () => {
 45 |       const properties = [
 46 |         { name: 'url', type: 'string', required: true },
 47 |         { name: 'method', type: 'options', options: ['GET', 'POST'] },
 48 |         { name: 'authentication', type: 'options' },
 49 |         { name: 'sendBody', type: 'boolean' },
 50 |         { name: 'contentType', type: 'options' },
 51 |         { name: 'sendHeaders', type: 'boolean' },
 52 |         { name: 'someRareOption', type: 'string' },
 53 |       ];
 54 | 
 55 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.httpRequest');
 56 | 
 57 |       expect(result.required).toHaveLength(1);
 58 |       expect(result.required[0].name).toBe('url');
 59 |       expect(result.required[0].required).toBe(true);
 60 |       
 61 |       expect(result.common).toHaveLength(5);
 62 |       expect(result.common.map(p => p.name)).toEqual([
 63 |         'method',
 64 |         'authentication',
 65 |         'sendBody',
 66 |         'contentType',
 67 |         'sendHeaders'
 68 |       ]);
 69 |     });
 70 | 
 71 |     it('should handle nested properties in collections', () => {
 72 |       const properties = [
 73 |         {
 74 |           name: 'assignments',
 75 |           type: 'collection',
 76 |           options: [
 77 |             { name: 'field', type: 'string' },
 78 |             { name: 'value', type: 'string' }
 79 |           ]
 80 |         }
 81 |       ];
 82 | 
 83 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.set');
 84 | 
 85 |       expect(result.common.some(p => p.name === 'assignments')).toBe(true);
 86 |     });
 87 | 
 88 |     it('should infer essentials for unconfigured nodes', () => {
 89 |       const properties = [
 90 |         { name: 'requiredField', type: 'string', required: true },
 91 |         { name: 'simpleField', type: 'string' },
 92 |         { name: 'conditionalField', type: 'string', displayOptions: { show: { mode: ['advanced'] } } },
 93 |         { name: 'complexField', type: 'collection' },
 94 |       ];
 95 | 
 96 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
 97 | 
 98 |       expect(result.required).toHaveLength(1);
 99 |       expect(result.required[0].name).toBe('requiredField');
100 |       
101 |       // May include both simpleField and complexField (collection type)
102 |       expect(result.common.length).toBeGreaterThanOrEqual(1);
103 |       expect(result.common.some(p => p.name === 'simpleField')).toBe(true);
104 |     });
105 | 
106 |     it('should include conditional properties when needed to reach minimum count', () => {
107 |       const properties = [
108 |         { name: 'field1', type: 'string' },
109 |         { name: 'field2', type: 'string', displayOptions: { show: { mode: ['basic'] } } },
110 |         { name: 'field3', type: 'string', displayOptions: { show: { mode: ['advanced'], type: ['custom'] } } },
111 |       ];
112 | 
113 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
114 | 
115 |       expect(result.common).toHaveLength(2);
116 |       expect(result.common[0].name).toBe('field1');
117 |       expect(result.common[1].name).toBe('field2'); // Single condition included
118 |     });
119 |   });
120 | 
121 |   describe('property simplification', () => {
122 |     it('should simplify options properly', () => {
123 |       const properties = [
124 |         {
125 |           name: 'method',
126 |           type: 'options',
127 |           displayName: 'HTTP Method',
128 |           options: [
129 |             { name: 'GET', value: 'GET' },
130 |             { name: 'POST', value: 'POST' },
131 |             { name: 'PUT', value: 'PUT' }
132 |           ]
133 |         }
134 |       ];
135 | 
136 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.httpRequest');
137 | 
138 |       const methodProp = result.common.find(p => p.name === 'method');
139 |       expect(methodProp?.options).toHaveLength(3);
140 |       expect(methodProp?.options?.[0]).toEqual({ value: 'GET', label: 'GET' });
141 |     });
142 | 
143 |     it('should handle string array options', () => {
144 |       const properties = [
145 |         {
146 |           name: 'resource',
147 |           type: 'options',
148 |           options: ['user', 'post', 'comment']
149 |         }
150 |       ];
151 | 
152 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
153 | 
154 |       const resourceProp = result.common.find(p => p.name === 'resource');
155 |       expect(resourceProp?.options).toEqual([
156 |         { value: 'user', label: 'user' },
157 |         { value: 'post', label: 'post' },
158 |         { value: 'comment', label: 'comment' }
159 |       ]);
160 |     });
161 | 
162 |     it('should include simple display conditions', () => {
163 |       const properties = [
164 |         {
165 |           name: 'channel',
166 |           type: 'string',
167 |           displayOptions: {
168 |             show: {
169 |               resource: ['message'],
170 |               operation: ['post']
171 |             }
172 |           }
173 |         }
174 |       ];
175 | 
176 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.slack');
177 | 
178 |       const channelProp = result.common.find(p => p.name === 'channel');
179 |       expect(channelProp?.showWhen).toEqual({
180 |         resource: ['message'],
181 |         operation: ['post']
182 |       });
183 |     });
184 | 
185 |     it('should exclude complex display conditions', () => {
186 |       const properties = [
187 |         {
188 |           name: 'complexField',
189 |           type: 'string',
190 |           displayOptions: {
191 |             show: {
192 |               mode: ['advanced'],
193 |               type: ['custom'],
194 |               enabled: [true],
195 |               resource: ['special']
196 |             }
197 |           }
198 |         }
199 |       ];
200 | 
201 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
202 | 
203 |       const complexProp = result.common.find(p => p.name === 'complexField');
204 |       expect(complexProp?.showWhen).toBeUndefined();
205 |     });
206 | 
207 |     it('should generate usage hints for common property types', () => {
208 |       const properties = [
209 |         { name: 'url', type: 'string' },
210 |         { name: 'endpoint', type: 'string' },
211 |         { name: 'authentication', type: 'options' },
212 |         { name: 'jsonData', type: 'json' },
213 |         { name: 'jsCode', type: 'code' },
214 |         { name: 'enableFeature', type: 'boolean', displayOptions: { show: { mode: ['advanced'] } } }
215 |       ];
216 | 
217 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
218 | 
219 |       const urlProp = result.common.find(p => p.name === 'url');
220 |       expect(urlProp?.usageHint).toBe('Enter the full URL including https://');
221 | 
222 |       const authProp = result.common.find(p => p.name === 'authentication');
223 |       expect(authProp?.usageHint).toBe('Select authentication method or credentials');
224 | 
225 |       const jsonProp = result.common.find(p => p.name === 'jsonData');
226 |       expect(jsonProp?.usageHint).toBe('Enter valid JSON data');
227 |     });
228 | 
229 |     it('should extract descriptions from various fields', () => {
230 |       const properties = [
231 |         { name: 'field1', description: 'Primary description' },
232 |         { name: 'field2', hint: 'Hint description' },
233 |         { name: 'field3', placeholder: 'Placeholder description' },
234 |         { name: 'field4', displayName: 'Display Name Only' },
235 |         { name: 'url' } // Should generate description
236 |       ];
237 | 
238 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
239 | 
240 |       expect(result.common[0].description).toBe('Primary description');
241 |       expect(result.common[1].description).toBe('Hint description');
242 |       expect(result.common[2].description).toBe('Placeholder description');
243 |       expect(result.common[3].description).toBe('Display Name Only');
244 |       expect(result.common[4].description).toBe('The URL to make the request to');
245 |     });
246 |   });
247 | 
248 |   describe('searchProperties', () => {
249 |     const testProperties = [
250 |       { 
251 |         name: 'url', 
252 |         displayName: 'URL', 
253 |         type: 'string',
254 |         description: 'The endpoint URL for the request' 
255 |       },
256 |       { 
257 |         name: 'urlParams', 
258 |         displayName: 'URL Parameters', 
259 |         type: 'collection' 
260 |       },
261 |       { 
262 |         name: 'authentication', 
263 |         displayName: 'Authentication', 
264 |         type: 'options',
265 |         description: 'Select the authentication method' 
266 |       },
267 |       {
268 |         name: 'headers',
269 |         type: 'collection',
270 |         options: [
271 |           { name: 'Authorization', type: 'string' },
272 |           { name: 'Content-Type', type: 'string' }
273 |         ]
274 |       }
275 |     ];
276 | 
277 |     it('should find exact name matches with highest score', () => {
278 |       const results = PropertyFilter.searchProperties(testProperties, 'url');
279 | 
280 |       expect(results).toHaveLength(2);
281 |       expect(results[0].name).toBe('url'); // Exact match
282 |       expect(results[1].name).toBe('urlParams'); // Prefix match
283 |     });
284 | 
285 |     it('should find properties by partial name match', () => {
286 |       const results = PropertyFilter.searchProperties(testProperties, 'auth');
287 | 
288 |       // May match both 'authentication' and 'Authorization' in headers
289 |       expect(results.length).toBeGreaterThanOrEqual(1);
290 |       expect(results.some(r => r.name === 'authentication')).toBe(true);
291 |     });
292 | 
293 |     it('should find properties by description match', () => {
294 |       const results = PropertyFilter.searchProperties(testProperties, 'endpoint');
295 | 
296 |       expect(results).toHaveLength(1);
297 |       expect(results[0].name).toBe('url');
298 |     });
299 | 
300 |     it('should search nested properties in collections', () => {
301 |       const results = PropertyFilter.searchProperties(testProperties, 'authorization');
302 | 
303 |       expect(results).toHaveLength(1);
304 |       expect(results[0].name).toBe('Authorization');
305 |       expect((results[0] as any).path).toBe('headers.Authorization');
306 |     });
307 | 
308 |     it('should limit results to maxResults', () => {
309 |       const manyProperties = Array.from({ length: 30 }, (_, i) => ({
310 |         name: `authField${i}`,
311 |         type: 'string'
312 |       }));
313 | 
314 |       const results = PropertyFilter.searchProperties(manyProperties, 'auth', 5);
315 | 
316 |       expect(results).toHaveLength(5);
317 |     });
318 | 
319 |     it('should handle empty query gracefully', () => {
320 |       const results = PropertyFilter.searchProperties(testProperties, '');
321 | 
322 |       expect(results).toHaveLength(0);
323 |     });
324 | 
325 |     it('should search in fixedCollection properties', () => {
326 |       const properties = [
327 |         {
328 |           name: 'options',
329 |           type: 'fixedCollection',
330 |           options: [
331 |             {
332 |               name: 'advanced',
333 |               values: [
334 |                 { name: 'timeout', type: 'number' },
335 |                 { name: 'retries', type: 'number' }
336 |               ]
337 |             }
338 |           ]
339 |         }
340 |       ];
341 | 
342 |       const results = PropertyFilter.searchProperties(properties, 'timeout');
343 | 
344 |       expect(results).toHaveLength(1);
345 |       expect(results[0].name).toBe('timeout');
346 |       expect((results[0] as any).path).toBe('options.advanced.timeout');
347 |     });
348 |   });
349 | 
350 |   describe('edge cases', () => {
351 |     it('should handle empty properties array', () => {
352 |       const result = PropertyFilter.getEssentials([], 'nodes-base.httpRequest');
353 | 
354 |       expect(result.required).toHaveLength(0);
355 |       expect(result.common).toHaveLength(0);
356 |     });
357 | 
358 |     it('should handle properties with missing fields gracefully', () => {
359 |       const properties = [
360 |         { name: 'field1' }, // No type
361 |         { type: 'string' }, // No name
362 |         { name: 'field2', type: 'string' } // Valid
363 |       ];
364 | 
365 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
366 | 
367 |       expect(result.common.length).toBeGreaterThan(0);
368 |       expect(result.common.every(p => p.name && p.type)).toBe(true);
369 |     });
370 | 
371 |     it('should handle circular references in nested properties', () => {
372 |       const circularProp: any = {
373 |         name: 'circular',
374 |         type: 'collection',
375 |         options: []
376 |       };
377 |       circularProp.options.push(circularProp); // Create circular reference
378 | 
379 |       const properties = [circularProp, { name: 'normal', type: 'string' }];
380 | 
381 |       // Should not throw or hang
382 |       expect(() => {
383 |         PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
384 |       }).not.toThrow();
385 |     });
386 | 
387 |     it('should preserve default values for simple types', () => {
388 |       const properties = [
389 |         { name: 'method', type: 'options', default: 'GET' },
390 |         { name: 'timeout', type: 'number', default: 30000 },
391 |         { name: 'enabled', type: 'boolean', default: true },
392 |         { name: 'complex', type: 'collection', default: { key: 'value' } } // Should not include
393 |       ];
394 | 
395 |       const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode');
396 | 
397 |       const method = result.common.find(p => p.name === 'method');
398 |       expect(method?.default).toBe('GET');
399 | 
400 |       const timeout = result.common.find(p => p.name === 'timeout');
401 |       expect(timeout?.default).toBe(30000);
402 | 
403 |       const enabled = result.common.find(p => p.name === 'enabled');
404 |       expect(enabled?.default).toBe(true);
405 | 
406 |       const complex = result.common.find(p => p.name === 'complex');
407 |       expect(complex?.default).toBeUndefined();
408 |     });
409 |   });
410 | });
```

--------------------------------------------------------------------------------
/tests/unit/utils/cache-utils.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Unit tests for cache utilities
  3 |  */
  4 | 
  5 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  6 | import {
  7 |   createCacheKey,
  8 |   getCacheConfig,
  9 |   createInstanceCache,
 10 |   CacheMutex,
 11 |   calculateBackoffDelay,
 12 |   withRetry,
 13 |   getCacheStatistics,
 14 |   cacheMetrics,
 15 |   DEFAULT_RETRY_CONFIG
 16 | } from '../../../src/utils/cache-utils';
 17 | 
 18 | describe('cache-utils', () => {
 19 |   beforeEach(() => {
 20 |     // Reset environment variables
 21 |     delete process.env.INSTANCE_CACHE_MAX;
 22 |     delete process.env.INSTANCE_CACHE_TTL_MINUTES;
 23 |     // Reset cache metrics
 24 |     cacheMetrics.reset();
 25 |   });
 26 | 
 27 |   describe('createCacheKey', () => {
 28 |     it('should create consistent SHA-256 hash for same input', () => {
 29 |       const input = 'https://api.n8n.cloud:valid-key:instance1';
 30 |       const hash1 = createCacheKey(input);
 31 |       const hash2 = createCacheKey(input);
 32 | 
 33 |       expect(hash1).toBe(hash2);
 34 |       expect(hash1).toHaveLength(64); // SHA-256 produces 64 hex chars
 35 |       expect(hash1).toMatch(/^[a-f0-9]+$/); // Only hex characters
 36 |     });
 37 | 
 38 |     it('should produce different hashes for different inputs', () => {
 39 |       const hash1 = createCacheKey('input1');
 40 |       const hash2 = createCacheKey('input2');
 41 | 
 42 |       expect(hash1).not.toBe(hash2);
 43 |     });
 44 | 
 45 |     it('should use memoization for repeated inputs', () => {
 46 |       const input = 'memoized-input';
 47 | 
 48 |       // First call creates hash
 49 |       const hash1 = createCacheKey(input);
 50 | 
 51 |       // Second call should return memoized result
 52 |       const hash2 = createCacheKey(input);
 53 | 
 54 |       expect(hash1).toBe(hash2);
 55 |     });
 56 | 
 57 |     it('should limit memoization cache size', () => {
 58 |       // Create more than MAX_MEMO_SIZE (1000) unique hashes
 59 |       const hashes = new Set<string>();
 60 |       for (let i = 0; i < 1100; i++) {
 61 |         const hash = createCacheKey(`input-${i}`);
 62 |         hashes.add(hash);
 63 |       }
 64 | 
 65 |       // All hashes should be unique
 66 |       expect(hashes.size).toBe(1100);
 67 | 
 68 |       // Early entries should have been evicted from memo cache
 69 |       // but should still produce consistent results
 70 |       const earlyHash = createCacheKey('input-0');
 71 |       expect(earlyHash).toBe(hashes.values().next().value);
 72 |     });
 73 |   });
 74 | 
 75 |   describe('getCacheConfig', () => {
 76 |     it('should return default configuration when no env vars set', () => {
 77 |       const config = getCacheConfig();
 78 | 
 79 |       expect(config.max).toBe(100);
 80 |       expect(config.ttlMinutes).toBe(30);
 81 |     });
 82 | 
 83 |     it('should use environment variables when set', () => {
 84 |       process.env.INSTANCE_CACHE_MAX = '500';
 85 |       process.env.INSTANCE_CACHE_TTL_MINUTES = '60';
 86 | 
 87 |       const config = getCacheConfig();
 88 | 
 89 |       expect(config.max).toBe(500);
 90 |       expect(config.ttlMinutes).toBe(60);
 91 |     });
 92 | 
 93 |     it('should enforce minimum bounds', () => {
 94 |       process.env.INSTANCE_CACHE_MAX = '0';
 95 |       process.env.INSTANCE_CACHE_TTL_MINUTES = '0';
 96 | 
 97 |       const config = getCacheConfig();
 98 | 
 99 |       expect(config.max).toBe(1); // Min is 1
100 |       expect(config.ttlMinutes).toBe(1); // Min is 1
101 |     });
102 | 
103 |     it('should enforce maximum bounds', () => {
104 |       process.env.INSTANCE_CACHE_MAX = '20000';
105 |       process.env.INSTANCE_CACHE_TTL_MINUTES = '2000';
106 | 
107 |       const config = getCacheConfig();
108 | 
109 |       expect(config.max).toBe(10000); // Max is 10000
110 |       expect(config.ttlMinutes).toBe(1440); // Max is 1440 (24 hours)
111 |     });
112 | 
113 |     it('should handle invalid values gracefully', () => {
114 |       process.env.INSTANCE_CACHE_MAX = 'invalid';
115 |       process.env.INSTANCE_CACHE_TTL_MINUTES = 'not-a-number';
116 | 
117 |       const config = getCacheConfig();
118 | 
119 |       expect(config.max).toBe(100); // Falls back to default
120 |       expect(config.ttlMinutes).toBe(30); // Falls back to default
121 |     });
122 |   });
123 | 
124 |   describe('createInstanceCache', () => {
125 |     it('should create LRU cache with correct configuration', () => {
126 |       process.env.INSTANCE_CACHE_MAX = '50';
127 |       process.env.INSTANCE_CACHE_TTL_MINUTES = '15';
128 | 
129 |       const cache = createInstanceCache<{ data: string }>();
130 | 
131 |       // Add items to cache
132 |       cache.set('key1', { data: 'value1' });
133 |       cache.set('key2', { data: 'value2' });
134 | 
135 |       expect(cache.get('key1')).toEqual({ data: 'value1' });
136 |       expect(cache.get('key2')).toEqual({ data: 'value2' });
137 |       expect(cache.size).toBe(2);
138 |     });
139 | 
140 |     it('should call dispose callback on eviction', () => {
141 |       const disposeFn = vi.fn();
142 |       const cache = createInstanceCache<{ data: string }>(disposeFn);
143 | 
144 |       // Set max to 2 for testing
145 |       process.env.INSTANCE_CACHE_MAX = '2';
146 |       const smallCache = createInstanceCache<{ data: string }>(disposeFn);
147 | 
148 |       smallCache.set('key1', { data: 'value1' });
149 |       smallCache.set('key2', { data: 'value2' });
150 |       smallCache.set('key3', { data: 'value3' }); // Should evict key1
151 | 
152 |       expect(disposeFn).toHaveBeenCalledWith({ data: 'value1' }, 'key1');
153 |     });
154 | 
155 |     it('should update age on get', () => {
156 |       const cache = createInstanceCache<{ data: string }>();
157 | 
158 |       cache.set('key1', { data: 'value1' });
159 | 
160 |       // Access should update age
161 |       const value = cache.get('key1');
162 |       expect(value).toEqual({ data: 'value1' });
163 | 
164 |       // Item should still be in cache
165 |       expect(cache.has('key1')).toBe(true);
166 |     });
167 |   });
168 | 
169 |   describe('CacheMutex', () => {
170 |     it('should prevent concurrent access to same key', async () => {
171 |       const mutex = new CacheMutex();
172 |       const key = 'test-key';
173 |       const results: number[] = [];
174 | 
175 |       // First operation acquires lock
176 |       const release1 = await mutex.acquire(key);
177 | 
178 |       // Second operation should wait
179 |       const promise2 = mutex.acquire(key).then(release => {
180 |         results.push(2);
181 |         release();
182 |       });
183 | 
184 |       // First operation completes
185 |       results.push(1);
186 |       release1();
187 | 
188 |       // Wait for second operation
189 |       await promise2;
190 | 
191 |       expect(results).toEqual([1, 2]); // Operations executed in order
192 |     });
193 | 
194 |     it('should allow concurrent access to different keys', async () => {
195 |       const mutex = new CacheMutex();
196 |       const results: string[] = [];
197 | 
198 |       const [release1, release2] = await Promise.all([
199 |         mutex.acquire('key1'),
200 |         mutex.acquire('key2')
201 |       ]);
202 | 
203 |       results.push('both-acquired');
204 |       release1();
205 |       release2();
206 | 
207 |       expect(results).toEqual(['both-acquired']);
208 |     });
209 | 
210 |     it('should check if key is locked', async () => {
211 |       const mutex = new CacheMutex();
212 |       const key = 'test-key';
213 | 
214 |       expect(mutex.isLocked(key)).toBe(false);
215 | 
216 |       const release = await mutex.acquire(key);
217 |       expect(mutex.isLocked(key)).toBe(true);
218 | 
219 |       release();
220 |       expect(mutex.isLocked(key)).toBe(false);
221 |     });
222 | 
223 |     it('should clear all locks', async () => {
224 |       const mutex = new CacheMutex();
225 | 
226 |       const release1 = await mutex.acquire('key1');
227 |       const release2 = await mutex.acquire('key2');
228 | 
229 |       expect(mutex.isLocked('key1')).toBe(true);
230 |       expect(mutex.isLocked('key2')).toBe(true);
231 | 
232 |       mutex.clearAll();
233 | 
234 |       expect(mutex.isLocked('key1')).toBe(false);
235 |       expect(mutex.isLocked('key2')).toBe(false);
236 | 
237 |       // Should not throw when calling release after clear
238 |       release1();
239 |       release2();
240 |     });
241 | 
242 |     it('should handle timeout for stuck locks', async () => {
243 |       const mutex = new CacheMutex();
244 |       const key = 'stuck-key';
245 | 
246 |       // Acquire lock but don't release
247 |       await mutex.acquire(key);
248 | 
249 |       // Wait for timeout (mock the timeout)
250 |       vi.useFakeTimers();
251 | 
252 |       // Try to acquire same lock
253 |       const acquirePromise = mutex.acquire(key);
254 | 
255 |       // Fast-forward past timeout
256 |       vi.advanceTimersByTime(6000); // Timeout is 5 seconds
257 | 
258 |       // Should be able to acquire after timeout
259 |       const release = await acquirePromise;
260 |       release();
261 | 
262 |       vi.useRealTimers();
263 |     });
264 |   });
265 | 
266 |   describe('calculateBackoffDelay', () => {
267 |     it('should calculate exponential backoff correctly', () => {
268 |       const config = { ...DEFAULT_RETRY_CONFIG, jitterFactor: 0 }; // No jitter for predictable tests
269 | 
270 |       expect(calculateBackoffDelay(0, config)).toBe(1000); // 1 * 1000
271 |       expect(calculateBackoffDelay(1, config)).toBe(2000); // 2 * 1000
272 |       expect(calculateBackoffDelay(2, config)).toBe(4000); // 4 * 1000
273 |       expect(calculateBackoffDelay(3, config)).toBe(8000); // 8 * 1000
274 |     });
275 | 
276 |     it('should respect max delay', () => {
277 |       const config = {
278 |         ...DEFAULT_RETRY_CONFIG,
279 |         maxDelayMs: 5000,
280 |         jitterFactor: 0
281 |       };
282 | 
283 |       expect(calculateBackoffDelay(10, config)).toBe(5000); // Capped at max
284 |     });
285 | 
286 |     it('should add jitter', () => {
287 |       const config = {
288 |         ...DEFAULT_RETRY_CONFIG,
289 |         baseDelayMs: 1000,
290 |         jitterFactor: 0.5
291 |       };
292 | 
293 |       const delay = calculateBackoffDelay(0, config);
294 | 
295 |       // With 50% jitter, delay should be between 1000 and 1500
296 |       expect(delay).toBeGreaterThanOrEqual(1000);
297 |       expect(delay).toBeLessThanOrEqual(1500);
298 |     });
299 |   });
300 | 
301 |   describe('withRetry', () => {
302 |     it('should succeed on first attempt', async () => {
303 |       const fn = vi.fn().mockResolvedValue('success');
304 | 
305 |       const result = await withRetry(fn);
306 | 
307 |       expect(result).toBe('success');
308 |       expect(fn).toHaveBeenCalledTimes(1);
309 |     });
310 | 
311 |     it('should retry on failure and eventually succeed', async () => {
312 |       // Create retryable errors (503 Service Unavailable)
313 |       const retryableError1 = new Error('Service temporarily unavailable');
314 |       (retryableError1 as any).response = { status: 503 };
315 | 
316 |       const retryableError2 = new Error('Another temporary failure');
317 |       (retryableError2 as any).response = { status: 503 };
318 | 
319 |       const fn = vi.fn()
320 |         .mockRejectedValueOnce(retryableError1)
321 |         .mockRejectedValueOnce(retryableError2)
322 |         .mockResolvedValue('success');
323 | 
324 |       const result = await withRetry(fn, {
325 |         maxAttempts: 3,
326 |         baseDelayMs: 10,
327 |         maxDelayMs: 100,
328 |         jitterFactor: 0
329 |       });
330 | 
331 |       expect(result).toBe('success');
332 |       expect(fn).toHaveBeenCalledTimes(3);
333 |     });
334 | 
335 |     it('should throw after max attempts', async () => {
336 |       // Create retryable error (503 Service Unavailable)
337 |       const retryableError = new Error('Persistent failure');
338 |       (retryableError as any).response = { status: 503 };
339 | 
340 |       const fn = vi.fn().mockRejectedValue(retryableError);
341 | 
342 |       await expect(withRetry(fn, {
343 |         maxAttempts: 3,
344 |         baseDelayMs: 10,
345 |         maxDelayMs: 100,
346 |         jitterFactor: 0
347 |       })).rejects.toThrow('Persistent failure');
348 | 
349 |       expect(fn).toHaveBeenCalledTimes(3);
350 |     });
351 | 
352 |     it('should not retry non-retryable errors', async () => {
353 |       const error = new Error('Not retryable');
354 |       (error as any).response = { status: 400 }; // Client error
355 | 
356 |       const fn = vi.fn().mockRejectedValue(error);
357 | 
358 |       await expect(withRetry(fn)).rejects.toThrow('Not retryable');
359 |       expect(fn).toHaveBeenCalledTimes(1); // No retry
360 |     });
361 | 
362 |     it('should retry network errors', async () => {
363 |       const networkError = new Error('Network error');
364 |       (networkError as any).code = 'ECONNREFUSED';
365 | 
366 |       const fn = vi.fn()
367 |         .mockRejectedValueOnce(networkError)
368 |         .mockResolvedValue('success');
369 | 
370 |       const result = await withRetry(fn, {
371 |         maxAttempts: 2,
372 |         baseDelayMs: 10,
373 |         maxDelayMs: 100,
374 |         jitterFactor: 0
375 |       });
376 | 
377 |       expect(result).toBe('success');
378 |       expect(fn).toHaveBeenCalledTimes(2);
379 |     });
380 | 
381 |     it('should retry 429 Too Many Requests', async () => {
382 |       const error = new Error('Rate limited');
383 |       (error as any).response = { status: 429 };
384 | 
385 |       const fn = vi.fn()
386 |         .mockRejectedValueOnce(error)
387 |         .mockResolvedValue('success');
388 | 
389 |       const result = await withRetry(fn, {
390 |         maxAttempts: 2,
391 |         baseDelayMs: 10,
392 |         maxDelayMs: 100,
393 |         jitterFactor: 0
394 |       });
395 | 
396 |       expect(result).toBe('success');
397 |       expect(fn).toHaveBeenCalledTimes(2);
398 |     });
399 |   });
400 | 
401 |   describe('cacheMetrics', () => {
402 |     it('should track cache operations', () => {
403 |       cacheMetrics.recordHit();
404 |       cacheMetrics.recordHit();
405 |       cacheMetrics.recordMiss();
406 |       cacheMetrics.recordSet();
407 |       cacheMetrics.recordDelete();
408 |       cacheMetrics.recordEviction();
409 | 
410 |       const metrics = cacheMetrics.getMetrics();
411 | 
412 |       expect(metrics.hits).toBe(2);
413 |       expect(metrics.misses).toBe(1);
414 |       expect(metrics.sets).toBe(1);
415 |       expect(metrics.deletes).toBe(1);
416 |       expect(metrics.evictions).toBe(1);
417 |       expect(metrics.avgHitRate).toBeCloseTo(0.667, 2); // 2/3
418 |     });
419 | 
420 |     it('should update cache size', () => {
421 |       cacheMetrics.updateSize(50, 100);
422 | 
423 |       const metrics = cacheMetrics.getMetrics();
424 | 
425 |       expect(metrics.size).toBe(50);
426 |       expect(metrics.maxSize).toBe(100);
427 |     });
428 | 
429 |     it('should reset metrics', () => {
430 |       cacheMetrics.recordHit();
431 |       cacheMetrics.recordMiss();
432 |       cacheMetrics.reset();
433 | 
434 |       const metrics = cacheMetrics.getMetrics();
435 | 
436 |       expect(metrics.hits).toBe(0);
437 |       expect(metrics.misses).toBe(0);
438 |       expect(metrics.avgHitRate).toBe(0);
439 |     });
440 | 
441 |     it('should format metrics for logging', () => {
442 |       cacheMetrics.recordHit();
443 |       cacheMetrics.recordHit();
444 |       cacheMetrics.recordMiss();
445 |       cacheMetrics.updateSize(25, 100);
446 |       cacheMetrics.recordEviction();
447 | 
448 |       const formatted = cacheMetrics.getFormattedMetrics();
449 | 
450 |       expect(formatted).toContain('Hits=2');
451 |       expect(formatted).toContain('Misses=1');
452 |       expect(formatted).toContain('HitRate=66.67%');
453 |       expect(formatted).toContain('Size=25/100');
454 |       expect(formatted).toContain('Evictions=1');
455 |     });
456 |   });
457 | 
458 |   describe('getCacheStatistics', () => {
459 |     it('should return formatted statistics', () => {
460 |       cacheMetrics.recordHit();
461 |       cacheMetrics.recordHit();
462 |       cacheMetrics.recordMiss();
463 |       cacheMetrics.updateSize(30, 100);
464 | 
465 |       const stats = getCacheStatistics();
466 | 
467 |       expect(stats).toContain('Cache Statistics:');
468 |       expect(stats).toContain('Total Operations: 3');
469 |       expect(stats).toContain('Hit Rate: 66.67%');
470 |       expect(stats).toContain('Current Size: 30/100');
471 |     });
472 | 
473 |     it('should calculate runtime', () => {
474 |       const stats = getCacheStatistics();
475 | 
476 |       expect(stats).toContain('Runtime:');
477 |       expect(stats).toMatch(/Runtime: \d+ minutes/);
478 |     });
479 |   });
480 | });
```

--------------------------------------------------------------------------------
/scripts/test-n8n-integration.sh:
--------------------------------------------------------------------------------

```bash
  1 | #!/bin/bash
  2 | 
  3 | # Script to test n8n integration with n8n-mcp server
  4 | set -e
  5 | 
  6 | # Check for command line arguments
  7 | if [ "$1" == "--clear-api-key" ] || [ "$1" == "-c" ]; then
  8 |     echo "🗑️  Clearing saved n8n API key..."
  9 |     rm -f "$HOME/.n8n-mcp-test/.n8n-api-key"
 10 |     echo "✅ API key cleared. You'll be prompted for a new key on next run."
 11 |     exit 0
 12 | fi
 13 | 
 14 | if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
 15 |     echo "Usage: $0 [options]"
 16 |     echo ""
 17 |     echo "Options:"
 18 |     echo "  -h, --help           Show this help message"
 19 |     echo "  -c, --clear-api-key  Clear the saved n8n API key"
 20 |     echo ""
 21 |     echo "The script will save your n8n API key on first use and reuse it on"
 22 |     echo "subsequent runs. You can override the saved key at runtime or clear"
 23 |     echo "it with the --clear-api-key option."
 24 |     exit 0
 25 | fi
 26 | 
 27 | echo "🚀 Starting n8n integration test environment..."
 28 | 
 29 | # Colors for output
 30 | GREEN='\033[0;32m'
 31 | YELLOW='\033[1;33m'
 32 | RED='\033[0;31m'
 33 | BLUE='\033[0;34m'
 34 | NC='\033[0m' # No Color
 35 | 
 36 | # Configuration
 37 | N8N_PORT=5678
 38 | MCP_PORT=3001
 39 | AUTH_TOKEN="test-token-for-n8n-testing-minimum-32-chars"
 40 | 
 41 | # n8n data directory for persistence
 42 | N8N_DATA_DIR="$HOME/.n8n-mcp-test"
 43 | # API key storage file
 44 | API_KEY_FILE="$N8N_DATA_DIR/.n8n-api-key"
 45 | 
 46 | # Function to detect OS
 47 | detect_os() {
 48 |     if [[ "$OSTYPE" == "linux-gnu"* ]]; then
 49 |         if [ -f /etc/os-release ]; then
 50 |             . /etc/os-release
 51 |             echo "$ID"
 52 |         else
 53 |             echo "linux"
 54 |         fi
 55 |     elif [[ "$OSTYPE" == "darwin"* ]]; then
 56 |         echo "macos"
 57 |     elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then
 58 |         echo "windows"
 59 |     else
 60 |         echo "unknown"
 61 |     fi
 62 | }
 63 | 
 64 | # Function to check if Docker is installed
 65 | check_docker() {
 66 |     if command -v docker &> /dev/null; then
 67 |         echo -e "${GREEN}✅ Docker is installed${NC}"
 68 |         # Check if Docker daemon is running
 69 |         if ! docker info &> /dev/null; then
 70 |             echo -e "${YELLOW}⚠️  Docker is installed but not running${NC}"
 71 |             echo -e "${YELLOW}Please start Docker and run this script again${NC}"
 72 |             exit 1
 73 |         fi
 74 |         return 0
 75 |     else
 76 |         return 1
 77 |     fi
 78 | }
 79 | 
 80 | # Function to install Docker based on OS
 81 | install_docker() {
 82 |     local os=$(detect_os)
 83 |     echo -e "${YELLOW}📦 Docker is not installed. Attempting to install...${NC}"
 84 |     
 85 |     case $os in
 86 |         "ubuntu"|"debian")
 87 |             echo -e "${BLUE}Installing Docker on Ubuntu/Debian...${NC}"
 88 |             echo "This requires sudo privileges."
 89 |             sudo apt-get update
 90 |             sudo apt-get install -y ca-certificates curl gnupg
 91 |             sudo install -m 0755 -d /etc/apt/keyrings
 92 |             curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
 93 |             sudo chmod a+r /etc/apt/keyrings/docker.gpg
 94 |             echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
 95 |             sudo apt-get update
 96 |             sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
 97 |             sudo usermod -aG docker $USER
 98 |             echo -e "${GREEN}✅ Docker installed successfully${NC}"
 99 |             echo -e "${YELLOW}⚠️  Please log out and back in for group changes to take effect${NC}"
100 |             ;;
101 |         "fedora"|"rhel"|"centos")
102 |             echo -e "${BLUE}Installing Docker on Fedora/RHEL/CentOS...${NC}"
103 |             echo "This requires sudo privileges."
104 |             sudo dnf -y install dnf-plugins-core
105 |             sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo
106 |             sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
107 |             sudo systemctl start docker
108 |             sudo systemctl enable docker
109 |             sudo usermod -aG docker $USER
110 |             echo -e "${GREEN}✅ Docker installed successfully${NC}"
111 |             echo -e "${YELLOW}⚠️  Please log out and back in for group changes to take effect${NC}"
112 |             ;;
113 |         "macos")
114 |             echo -e "${BLUE}Installing Docker on macOS...${NC}"
115 |             if command -v brew &> /dev/null; then
116 |                 echo "Installing Docker Desktop via Homebrew..."
117 |                 brew install --cask docker
118 |                 echo -e "${GREEN}✅ Docker Desktop installed${NC}"
119 |                 echo -e "${YELLOW}⚠️  Please start Docker Desktop from Applications${NC}"
120 |             else
121 |                 echo -e "${RED}❌ Homebrew not found${NC}"
122 |                 echo "Please install Docker Desktop manually from:"
123 |                 echo "https://www.docker.com/products/docker-desktop/"
124 |             fi
125 |             ;;
126 |         "windows")
127 |             echo -e "${RED}❌ Windows detected${NC}"
128 |             echo "Please install Docker Desktop manually from:"
129 |             echo "https://www.docker.com/products/docker-desktop/"
130 |             ;;
131 |         *)
132 |             echo -e "${RED}❌ Unknown operating system: $os${NC}"
133 |             echo "Please install Docker manually from https://docs.docker.com/get-docker/"
134 |             ;;
135 |     esac
136 |     
137 |     # If we installed Docker on Linux, we need to restart for group changes
138 |     if [[ "$os" == "ubuntu" ]] || [[ "$os" == "debian" ]] || [[ "$os" == "fedora" ]] || [[ "$os" == "rhel" ]] || [[ "$os" == "centos" ]]; then
139 |         echo -e "${YELLOW}Please run 'newgrp docker' or log out and back in, then run this script again${NC}"
140 |         exit 0
141 |     fi
142 |     
143 |     exit 1
144 | }
145 | 
146 | # Check for Docker
147 | if ! check_docker; then
148 |     install_docker
149 | fi
150 | 
151 | # Check for jq (optional but recommended)
152 | if ! command -v jq &> /dev/null; then
153 |     echo -e "${YELLOW}⚠️  jq is not installed (optional)${NC}"
154 |     echo -e "${YELLOW}   Install it for pretty JSON output in tests${NC}"
155 | fi
156 | 
157 | # Function to cleanup on exit
158 | cleanup() {
159 |     echo -e "\n${YELLOW}🧹 Cleaning up...${NC}"
160 |     
161 |     # Stop n8n container
162 |     if docker ps -q -f name=n8n-test > /dev/null 2>&1; then
163 |         echo "Stopping n8n container..."
164 |         docker stop n8n-test >/dev/null 2>&1 || true
165 |         docker rm n8n-test >/dev/null 2>&1 || true
166 |     fi
167 |     
168 |     # Kill MCP server if running
169 |     if [ -n "$MCP_PID" ] && kill -0 $MCP_PID 2>/dev/null; then
170 |         echo "Stopping MCP server..."
171 |         kill $MCP_PID 2>/dev/null || true
172 |     fi
173 |     
174 |     echo -e "${GREEN}✅ Cleanup complete${NC}"
175 | }
176 | 
177 | # Set trap to cleanup on exit
178 | trap cleanup EXIT INT TERM
179 | 
180 | # Check if we're in the right directory
181 | if [ ! -f "package.json" ] || [ ! -d "dist" ]; then
182 |     echo -e "${RED}❌ Error: Must run from n8n-mcp directory${NC}"
183 |     echo "Please cd to /Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp"
184 |     exit 1
185 | fi
186 | 
187 | # Always build the project to ensure latest changes
188 | echo -e "${YELLOW}📦 Building project...${NC}"
189 | npm run build
190 | 
191 | # Create n8n data directory if it doesn't exist
192 | if [ ! -d "$N8N_DATA_DIR" ]; then
193 |     echo -e "${YELLOW}📁 Creating n8n data directory: $N8N_DATA_DIR${NC}"
194 |     mkdir -p "$N8N_DATA_DIR"
195 | fi
196 | 
197 | # Start n8n in Docker with persistent volume
198 | echo -e "\n${GREEN}🐳 Starting n8n container with persistent data...${NC}"
199 | docker run -d \
200 |   --name n8n-test \
201 |   -p ${N8N_PORT}:5678 \
202 |   -v "${N8N_DATA_DIR}:/home/node/.n8n" \
203 |   -e N8N_BASIC_AUTH_ACTIVE=false \
204 |   -e N8N_HOST=localhost \
205 |   -e N8N_PORT=5678 \
206 |   -e N8N_PROTOCOL=http \
207 |   -e NODE_ENV=development \
208 |   -e N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true \
209 |   n8nio/n8n:latest
210 | 
211 | # Wait for n8n to be ready
212 | echo -e "${YELLOW}⏳ Waiting for n8n to start...${NC}"
213 | for i in {1..30}; do
214 |     if curl -s http://localhost:${N8N_PORT}/ >/dev/null 2>&1; then
215 |         echo -e "${GREEN}✅ n8n is ready!${NC}"
216 |         break
217 |     fi
218 |     if [ $i -eq 30 ]; then
219 |         echo -e "${RED}❌ n8n failed to start${NC}"
220 |         exit 1
221 |     fi
222 |     sleep 1
223 | done
224 | 
225 | # Check for saved API key
226 | if [ -f "$API_KEY_FILE" ]; then
227 |     # Read saved API key
228 |     N8N_API_KEY=$(cat "$API_KEY_FILE" 2>/dev/null || echo "")
229 |     
230 |     if [ -n "$N8N_API_KEY" ]; then
231 |         echo -e "\n${GREEN}✅ Using saved n8n API key${NC}"
232 |         echo -e "${YELLOW}   To use a different key, delete: ${API_KEY_FILE}${NC}"
233 |         
234 |         # Give user a chance to override
235 |         echo -e "\n${YELLOW}Press Enter to continue with saved key, or paste a new API key:${NC}"
236 |         read -r NEW_API_KEY
237 |         
238 |         if [ -n "$NEW_API_KEY" ]; then
239 |             N8N_API_KEY="$NEW_API_KEY"
240 |             # Save the new key
241 |             echo "$N8N_API_KEY" > "$API_KEY_FILE"
242 |             chmod 600 "$API_KEY_FILE"
243 |             echo -e "${GREEN}✅ New API key saved${NC}"
244 |         fi
245 |     else
246 |         # File exists but is empty, remove it
247 |         rm -f "$API_KEY_FILE"
248 |     fi
249 | fi
250 | 
251 | # If no saved key, prompt for one
252 | if [ -z "$N8N_API_KEY" ]; then
253 |     # Guide user to get API key
254 |     echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
255 |     echo -e "${YELLOW}🔑 n8n API Key Setup${NC}"
256 |     echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
257 |     echo -e "\nTo enable n8n management tools, you need to create an API key:"
258 |     echo -e "\n${GREEN}Steps:${NC}"
259 |     echo -e "  1. Open n8n in your browser: ${BLUE}http://localhost:${N8N_PORT}${NC}"
260 |     echo -e "  2. Click on your user menu (top right)"
261 |     echo -e "  3. Go to 'Settings'"
262 |     echo -e "  4. Navigate to 'API'"
263 |     echo -e "  5. Click 'Create API Key'"
264 |     echo -e "  6. Give it a name (e.g., 'n8n-mcp')"
265 |     echo -e "  7. Copy the generated API key"
266 |     echo -e "\n${YELLOW}Note: If this is your first time, you'll need to create an account first.${NC}"
267 |     echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
268 |     
269 |     # Wait for API key input
270 |     echo -e "\n${YELLOW}Please paste your n8n API key here (or press Enter to skip):${NC}"
271 |     read -r N8N_API_KEY
272 |     
273 |     # Save the API key if provided
274 |     if [ -n "$N8N_API_KEY" ]; then
275 |         echo "$N8N_API_KEY" > "$API_KEY_FILE"
276 |         chmod 600 "$API_KEY_FILE"
277 |         echo -e "${GREEN}✅ API key saved for future use${NC}"
278 |     fi
279 | fi
280 | 
281 | # Check if API key was provided
282 | if [ -z "$N8N_API_KEY" ]; then
283 |     echo -e "${YELLOW}⚠️  No API key provided. n8n management tools will not be available.${NC}"
284 |     echo -e "${YELLOW}   You can still use documentation and search tools.${NC}"
285 |     N8N_API_KEY=""
286 |     N8N_API_URL=""
287 | else
288 |     echo -e "${GREEN}✅ API key received${NC}"
289 |     # Set the API URL for localhost access (MCP server runs on host, not in Docker)
290 |     N8N_API_URL="http://localhost:${N8N_PORT}/api/v1"
291 | fi
292 | 
293 | # Start MCP server
294 | echo -e "\n${GREEN}🚀 Starting MCP server in n8n mode...${NC}"
295 | if [ -n "$N8N_API_KEY" ]; then
296 |     echo -e "${YELLOW}   With n8n management tools enabled${NC}"
297 | fi
298 | 
299 | N8N_MODE=true \
300 | MCP_MODE=http \
301 | AUTH_TOKEN="${AUTH_TOKEN}" \
302 | PORT=${MCP_PORT} \
303 | N8N_API_KEY="${N8N_API_KEY}" \
304 | N8N_API_URL="${N8N_API_URL}" \
305 | node dist/mcp/index.js > /tmp/mcp-server.log 2>&1 &
306 | 
307 | MCP_PID=$!
308 | 
309 | # Show log file location
310 | echo -e "${YELLOW}📄 MCP server logs: /tmp/mcp-server.log${NC}"
311 | 
312 | # Wait for MCP server to be ready
313 | echo -e "${YELLOW}⏳ Waiting for MCP server to start...${NC}"
314 | for i in {1..10}; do
315 |     if curl -s http://localhost:${MCP_PORT}/health >/dev/null 2>&1; then
316 |         echo -e "${GREEN}✅ MCP server is ready!${NC}"
317 |         break
318 |     fi
319 |     if [ $i -eq 10 ]; then
320 |         echo -e "${RED}❌ MCP server failed to start${NC}"
321 |         exit 1
322 |     fi
323 |     sleep 1
324 | done
325 | 
326 | # Show status and test endpoints
327 | echo -e "\n${GREEN}🎉 Both services are running!${NC}"
328 | echo -e "\n📍 Service URLs:"
329 | echo -e "  • n8n:        http://localhost:${N8N_PORT}"
330 | echo -e "  • MCP server: http://localhost:${MCP_PORT}"
331 | echo -e "\n🔑 Auth token: ${AUTH_TOKEN}"
332 | echo -e "\n💾 n8n data stored in: ${N8N_DATA_DIR}"
333 | echo -e "   (Your workflows, credentials, and settings are preserved between runs)"
334 | 
335 | # Test MCP protocol endpoint
336 | echo -e "\n${YELLOW}🧪 Testing MCP protocol endpoint...${NC}"
337 | echo "Response from GET /mcp:"
338 | curl -s http://localhost:${MCP_PORT}/mcp | jq '.' || curl -s http://localhost:${MCP_PORT}/mcp
339 | 
340 | # Test MCP initialization
341 | echo -e "\n${YELLOW}🧪 Testing MCP initialization...${NC}"
342 | echo "Response from POST /mcp (initialize):"
343 | curl -s -X POST http://localhost:${MCP_PORT}/mcp \
344 |   -H "Authorization: Bearer ${AUTH_TOKEN}" \
345 |   -H "Content-Type: application/json" \
346 |   -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}},"id":1}' \
347 |   | jq '.' || echo "(Install jq for pretty JSON output)"
348 | 
349 | # Test available tools
350 | echo -e "\n${YELLOW}🧪 Checking available MCP tools...${NC}"
351 | if [ -n "$N8N_API_KEY" ]; then
352 |     echo -e "${GREEN}✅ n8n Management Tools Available:${NC}"
353 |     echo "   • n8n_list_workflows - List all workflows"
354 |     echo "   • n8n_get_workflow - Get workflow details"
355 |     echo "   • n8n_create_workflow - Create new workflows"
356 |     echo "   • n8n_update_workflow - Update existing workflows"
357 |     echo "   • n8n_delete_workflow - Delete workflows"
358 |     echo "   • n8n_trigger_webhook_workflow - Trigger webhook workflows"
359 |     echo "   • n8n_list_executions - List workflow executions"
360 |     echo "   • And more..."
361 | else
362 |     echo -e "${YELLOW}⚠️  n8n Management Tools NOT Available${NC}"
363 |     echo "   To enable, restart with an n8n API key"
364 | fi
365 | 
366 | echo -e "\n${GREEN}✅ Documentation Tools Always Available:${NC}"
367 | echo "   • list_nodes - List available n8n nodes"
368 | echo "   • search_nodes - Search for specific nodes"
369 | echo "   • get_node_info - Get detailed node information"
370 | echo "   • validate_node_operation - Validate node configurations"
371 | echo "   • And many more..."
372 | 
373 | echo -e "\n${GREEN}✅ Setup complete!${NC}"
374 | echo -e "\n📝 Next steps:"
375 | echo -e "  1. Open n8n at http://localhost:${N8N_PORT}"
376 | echo -e "  2. Create a workflow with the AI Agent node"
377 | echo -e "  3. Add MCP Client Tool node"
378 | echo -e "  4. Configure it with:"
379 | echo -e "     • Transport: HTTP"
380 | echo -e "     • URL: http://host.docker.internal:${MCP_PORT}/mcp"
381 | echo -e "     • Auth Token: ${BLUE}${AUTH_TOKEN}${NC}"
382 | echo -e "\n${YELLOW}Press Ctrl+C to stop both services${NC}"
383 | echo -e "\n${YELLOW}📋 To monitor MCP logs: tail -f /tmp/mcp-server.log${NC}"
384 | echo -e "${YELLOW}📋 To monitor n8n logs: docker logs -f n8n-test${NC}"
385 | 
386 | # Wait for interrupt
387 | wait $MCP_PID
```

--------------------------------------------------------------------------------
/src/database/node-repository.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { DatabaseAdapter } from './database-adapter';
  2 | import { ParsedNode } from '../parsers/node-parser';
  3 | import { SQLiteStorageService } from '../services/sqlite-storage-service';
  4 | import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
  5 | 
  6 | export class NodeRepository {
  7 |   private db: DatabaseAdapter;
  8 |   
  9 |   constructor(dbOrService: DatabaseAdapter | SQLiteStorageService) {
 10 |     if (dbOrService instanceof SQLiteStorageService) {
 11 |       this.db = dbOrService.db;
 12 |       return;
 13 |     }
 14 | 
 15 |     this.db = dbOrService;
 16 |   }
 17 |   
 18 |   /**
 19 |    * Save node with proper JSON serialization
 20 |    */
 21 |   saveNode(node: ParsedNode): void {
 22 |     const stmt = this.db.prepare(`
 23 |       INSERT OR REPLACE INTO nodes (
 24 |         node_type, package_name, display_name, description,
 25 |         category, development_style, is_ai_tool, is_trigger,
 26 |         is_webhook, is_versioned, version, documentation,
 27 |         properties_schema, operations, credentials_required,
 28 |         outputs, output_names
 29 |       ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 30 |     `);
 31 |     
 32 |     stmt.run(
 33 |       node.nodeType,
 34 |       node.packageName,
 35 |       node.displayName,
 36 |       node.description,
 37 |       node.category,
 38 |       node.style,
 39 |       node.isAITool ? 1 : 0,
 40 |       node.isTrigger ? 1 : 0,
 41 |       node.isWebhook ? 1 : 0,
 42 |       node.isVersioned ? 1 : 0,
 43 |       node.version,
 44 |       node.documentation || null,
 45 |       JSON.stringify(node.properties, null, 2),
 46 |       JSON.stringify(node.operations, null, 2),
 47 |       JSON.stringify(node.credentials, null, 2),
 48 |       node.outputs ? JSON.stringify(node.outputs, null, 2) : null,
 49 |       node.outputNames ? JSON.stringify(node.outputNames, null, 2) : null
 50 |     );
 51 |   }
 52 |   
 53 |   /**
 54 |    * Get node with proper JSON deserialization
 55 |    * Automatically normalizes node type to full form for consistent lookups
 56 |    */
 57 |   getNode(nodeType: string): any {
 58 |     // Normalize to full form first for consistent lookups
 59 |     const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
 60 | 
 61 |     const row = this.db.prepare(`
 62 |       SELECT * FROM nodes WHERE node_type = ?
 63 |     `).get(normalizedType) as any;
 64 | 
 65 |     // Fallback: try original type if normalization didn't help (e.g., community nodes)
 66 |     if (!row && normalizedType !== nodeType) {
 67 |       const originalRow = this.db.prepare(`
 68 |         SELECT * FROM nodes WHERE node_type = ?
 69 |       `).get(nodeType) as any;
 70 | 
 71 |       if (originalRow) {
 72 |         return this.parseNodeRow(originalRow);
 73 |       }
 74 |     }
 75 | 
 76 |     if (!row) return null;
 77 | 
 78 |     return this.parseNodeRow(row);
 79 |   }
 80 |   
 81 |   /**
 82 |    * Get AI tools with proper filtering
 83 |    */
 84 |   getAITools(): any[] {
 85 |     const rows = this.db.prepare(`
 86 |       SELECT node_type, display_name, description, package_name
 87 |       FROM nodes 
 88 |       WHERE is_ai_tool = 1
 89 |       ORDER BY display_name
 90 |     `).all() as any[];
 91 |     
 92 |     return rows.map(row => ({
 93 |       nodeType: row.node_type,
 94 |       displayName: row.display_name,
 95 |       description: row.description,
 96 |       package: row.package_name
 97 |     }));
 98 |   }
 99 |   
100 |   private safeJsonParse(json: string, defaultValue: any): any {
101 |     try {
102 |       return JSON.parse(json);
103 |     } catch {
104 |       return defaultValue;
105 |     }
106 |   }
107 | 
108 |   // Additional methods for benchmarks
109 |   upsertNode(node: ParsedNode): void {
110 |     this.saveNode(node);
111 |   }
112 | 
113 |   getNodeByType(nodeType: string): any {
114 |     return this.getNode(nodeType);
115 |   }
116 | 
117 |   getNodesByCategory(category: string): any[] {
118 |     const rows = this.db.prepare(`
119 |       SELECT * FROM nodes WHERE category = ?
120 |       ORDER BY display_name
121 |     `).all(category) as any[];
122 |     
123 |     return rows.map(row => this.parseNodeRow(row));
124 |   }
125 | 
126 |   /**
127 |    * Legacy LIKE-based search method for direct repository usage.
128 |    *
129 |    * NOTE: MCP tools do NOT use this method. They use MCPServer.searchNodes()
130 |    * which automatically detects and uses FTS5 full-text search when available.
131 |    * See src/mcp/server.ts:1135-1148 for FTS5 implementation.
132 |    *
133 |    * This method remains for:
134 |    * - Direct repository access in scripts/benchmarks
135 |    * - Fallback when FTS5 table doesn't exist
136 |    * - Legacy compatibility
137 |    */
138 |   searchNodes(query: string, mode: 'OR' | 'AND' | 'FUZZY' = 'OR', limit: number = 20): any[] {
139 |     let sql = '';
140 |     const params: any[] = [];
141 | 
142 |     if (mode === 'FUZZY') {
143 |       // Simple fuzzy search
144 |       sql = `
145 |         SELECT * FROM nodes 
146 |         WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ?
147 |         ORDER BY display_name
148 |         LIMIT ?
149 |       `;
150 |       const fuzzyQuery = `%${query}%`;
151 |       params.push(fuzzyQuery, fuzzyQuery, fuzzyQuery, limit);
152 |     } else {
153 |       // OR/AND mode
154 |       const words = query.split(/\s+/).filter(w => w.length > 0);
155 |       const conditions = words.map(() => 
156 |         '(node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)'
157 |       );
158 |       const operator = mode === 'AND' ? ' AND ' : ' OR ';
159 |       
160 |       sql = `
161 |         SELECT * FROM nodes 
162 |         WHERE ${conditions.join(operator)}
163 |         ORDER BY display_name
164 |         LIMIT ?
165 |       `;
166 |       
167 |       for (const word of words) {
168 |         const searchTerm = `%${word}%`;
169 |         params.push(searchTerm, searchTerm, searchTerm);
170 |       }
171 |       params.push(limit);
172 |     }
173 |     
174 |     const rows = this.db.prepare(sql).all(...params) as any[];
175 |     return rows.map(row => this.parseNodeRow(row));
176 |   }
177 | 
178 |   getAllNodes(limit?: number): any[] {
179 |     let sql = 'SELECT * FROM nodes ORDER BY display_name';
180 |     if (limit) {
181 |       sql += ` LIMIT ${limit}`;
182 |     }
183 |     
184 |     const rows = this.db.prepare(sql).all() as any[];
185 |     return rows.map(row => this.parseNodeRow(row));
186 |   }
187 | 
188 |   getNodeCount(): number {
189 |     const result = this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any;
190 |     return result.count;
191 |   }
192 | 
193 |   getAIToolNodes(): any[] {
194 |     return this.getAITools();
195 |   }
196 | 
197 |   getNodesByPackage(packageName: string): any[] {
198 |     const rows = this.db.prepare(`
199 |       SELECT * FROM nodes WHERE package_name = ?
200 |       ORDER BY display_name
201 |     `).all(packageName) as any[];
202 |     
203 |     return rows.map(row => this.parseNodeRow(row));
204 |   }
205 | 
206 |   searchNodeProperties(nodeType: string, query: string, maxResults: number = 20): any[] {
207 |     const node = this.getNode(nodeType);
208 |     if (!node || !node.properties) return [];
209 |     
210 |     const results: any[] = [];
211 |     const searchLower = query.toLowerCase();
212 |     
213 |     function searchProperties(properties: any[], path: string[] = []) {
214 |       for (const prop of properties) {
215 |         if (results.length >= maxResults) break;
216 |         
217 |         const currentPath = [...path, prop.name || prop.displayName];
218 |         const pathString = currentPath.join('.');
219 |         
220 |         if (prop.name?.toLowerCase().includes(searchLower) ||
221 |             prop.displayName?.toLowerCase().includes(searchLower) ||
222 |             prop.description?.toLowerCase().includes(searchLower)) {
223 |           results.push({
224 |             path: pathString,
225 |             property: prop,
226 |             description: prop.description
227 |           });
228 |         }
229 |         
230 |         // Search nested properties
231 |         if (prop.options) {
232 |           searchProperties(prop.options, currentPath);
233 |         }
234 |       }
235 |     }
236 |     
237 |     searchProperties(node.properties);
238 |     return results;
239 |   }
240 | 
241 |   private parseNodeRow(row: any): any {
242 |     return {
243 |       nodeType: row.node_type,
244 |       displayName: row.display_name,
245 |       description: row.description,
246 |       category: row.category,
247 |       developmentStyle: row.development_style,
248 |       package: row.package_name,
249 |       isAITool: Number(row.is_ai_tool) === 1,
250 |       isTrigger: Number(row.is_trigger) === 1,
251 |       isWebhook: Number(row.is_webhook) === 1,
252 |       isVersioned: Number(row.is_versioned) === 1,
253 |       version: row.version,
254 |       properties: this.safeJsonParse(row.properties_schema, []),
255 |       operations: this.safeJsonParse(row.operations, []),
256 |       credentials: this.safeJsonParse(row.credentials_required, []),
257 |       hasDocumentation: !!row.documentation,
258 |       outputs: row.outputs ? this.safeJsonParse(row.outputs, null) : null,
259 |       outputNames: row.output_names ? this.safeJsonParse(row.output_names, null) : null
260 |     };
261 |   }
262 | 
263 |   /**
264 |    * Get operations for a specific node, optionally filtered by resource
265 |    */
266 |   getNodeOperations(nodeType: string, resource?: string): any[] {
267 |     const node = this.getNode(nodeType);
268 |     if (!node) return [];
269 | 
270 |     const operations: any[] = [];
271 | 
272 |     // Parse operations field
273 |     if (node.operations) {
274 |       if (Array.isArray(node.operations)) {
275 |         operations.push(...node.operations);
276 |       } else if (typeof node.operations === 'object') {
277 |         // Operations might be grouped by resource
278 |         if (resource && node.operations[resource]) {
279 |           return node.operations[resource];
280 |         } else {
281 |           // Return all operations
282 |           Object.values(node.operations).forEach(ops => {
283 |             if (Array.isArray(ops)) {
284 |               operations.push(...ops);
285 |             }
286 |           });
287 |         }
288 |       }
289 |     }
290 | 
291 |     // Also check properties for operation fields
292 |     if (node.properties && Array.isArray(node.properties)) {
293 |       for (const prop of node.properties) {
294 |         if (prop.name === 'operation' && prop.options) {
295 |           // If resource is specified, filter by displayOptions
296 |           if (resource && prop.displayOptions?.show?.resource) {
297 |             const allowedResources = Array.isArray(prop.displayOptions.show.resource)
298 |               ? prop.displayOptions.show.resource
299 |               : [prop.displayOptions.show.resource];
300 |             if (!allowedResources.includes(resource)) {
301 |               continue;
302 |             }
303 |           }
304 | 
305 |           // Add operations from this property
306 |           operations.push(...prop.options);
307 |         }
308 |       }
309 |     }
310 | 
311 |     return operations;
312 |   }
313 | 
314 |   /**
315 |    * Get all resources defined for a node
316 |    */
317 |   getNodeResources(nodeType: string): any[] {
318 |     const node = this.getNode(nodeType);
319 |     if (!node || !node.properties) return [];
320 | 
321 |     const resources: any[] = [];
322 | 
323 |     // Look for resource property
324 |     for (const prop of node.properties) {
325 |       if (prop.name === 'resource' && prop.options) {
326 |         resources.push(...prop.options);
327 |       }
328 |     }
329 | 
330 |     return resources;
331 |   }
332 | 
333 |   /**
334 |    * Get operations that are valid for a specific resource
335 |    */
336 |   getOperationsForResource(nodeType: string, resource: string): any[] {
337 |     const node = this.getNode(nodeType);
338 |     if (!node || !node.properties) return [];
339 | 
340 |     const operations: any[] = [];
341 | 
342 |     // Find operation properties that are visible for this resource
343 |     for (const prop of node.properties) {
344 |       if (prop.name === 'operation' && prop.displayOptions?.show?.resource) {
345 |         const allowedResources = Array.isArray(prop.displayOptions.show.resource)
346 |           ? prop.displayOptions.show.resource
347 |           : [prop.displayOptions.show.resource];
348 | 
349 |         if (allowedResources.includes(resource) && prop.options) {
350 |           operations.push(...prop.options);
351 |         }
352 |       }
353 |     }
354 | 
355 |     return operations;
356 |   }
357 | 
358 |   /**
359 |    * Get all operations across all nodes (for analysis)
360 |    */
361 |   getAllOperations(): Map<string, any[]> {
362 |     const allOperations = new Map<string, any[]>();
363 |     const nodes = this.getAllNodes();
364 | 
365 |     for (const node of nodes) {
366 |       const operations = this.getNodeOperations(node.nodeType);
367 |       if (operations.length > 0) {
368 |         allOperations.set(node.nodeType, operations);
369 |       }
370 |     }
371 | 
372 |     return allOperations;
373 |   }
374 | 
375 |   /**
376 |    * Get all resources across all nodes (for analysis)
377 |    */
378 |   getAllResources(): Map<string, any[]> {
379 |     const allResources = new Map<string, any[]>();
380 |     const nodes = this.getAllNodes();
381 | 
382 |     for (const node of nodes) {
383 |       const resources = this.getNodeResources(node.nodeType);
384 |       if (resources.length > 0) {
385 |         allResources.set(node.nodeType, resources);
386 |       }
387 |     }
388 | 
389 |     return allResources;
390 |   }
391 | 
392 |   /**
393 |    * Get default values for node properties
394 |    */
395 |   getNodePropertyDefaults(nodeType: string): Record<string, any> {
396 |     try {
397 |       const node = this.getNode(nodeType);
398 |       if (!node || !node.properties) return {};
399 | 
400 |       const defaults: Record<string, any> = {};
401 | 
402 |       for (const prop of node.properties) {
403 |         if (prop.name && prop.default !== undefined) {
404 |           defaults[prop.name] = prop.default;
405 |         }
406 |       }
407 | 
408 |       return defaults;
409 |     } catch (error) {
410 |       // Log error and return empty defaults rather than throwing
411 |       console.error(`Error getting property defaults for ${nodeType}:`, error);
412 |       return {};
413 |     }
414 |   }
415 | 
416 |   /**
417 |    * Get the default operation for a specific resource
418 |    */
419 |   getDefaultOperationForResource(nodeType: string, resource?: string): string | undefined {
420 |     try {
421 |       const node = this.getNode(nodeType);
422 |       if (!node || !node.properties) return undefined;
423 | 
424 |       // Find operation property that's visible for this resource
425 |       for (const prop of node.properties) {
426 |         if (prop.name === 'operation') {
427 |           // If there's a resource dependency, check if it matches
428 |           if (resource && prop.displayOptions?.show?.resource) {
429 |             // Validate displayOptions structure
430 |             const resourceDep = prop.displayOptions.show.resource;
431 |             if (!Array.isArray(resourceDep) && typeof resourceDep !== 'string') {
432 |               continue; // Skip malformed displayOptions
433 |             }
434 | 
435 |             const allowedResources = Array.isArray(resourceDep)
436 |               ? resourceDep
437 |               : [resourceDep];
438 | 
439 |             if (!allowedResources.includes(resource)) {
440 |               continue; // This operation property doesn't apply to our resource
441 |             }
442 |           }
443 | 
444 |           // Return the default value if it exists
445 |           if (prop.default !== undefined) {
446 |             return prop.default;
447 |           }
448 | 
449 |           // If no default but has options, return the first option's value
450 |           if (prop.options && Array.isArray(prop.options) && prop.options.length > 0) {
451 |             const firstOption = prop.options[0];
452 |             return typeof firstOption === 'string' ? firstOption : firstOption.value;
453 |           }
454 |         }
455 |       }
456 |     } catch (error) {
457 |       // Log error and return undefined rather than throwing
458 |       // This ensures validation continues even with malformed node data
459 |       console.error(`Error getting default operation for ${nodeType}:`, error);
460 |       return undefined;
461 |     }
462 | 
463 |     return undefined;
464 |   }
465 | }
```

--------------------------------------------------------------------------------
/src/telemetry/event-tracker.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Event Tracker for Telemetry (v2.18.3)
  3 |  * Handles all event tracking logic extracted from TelemetryManager
  4 |  * Now uses shared sanitization utilities to avoid code duplication
  5 |  */
  6 | 
  7 | import { TelemetryEvent, WorkflowTelemetry } from './telemetry-types';
  8 | import { WorkflowSanitizer } from './workflow-sanitizer';
  9 | import { TelemetryRateLimiter } from './rate-limiter';
 10 | import { TelemetryEventValidator } from './event-validator';
 11 | import { TelemetryError, TelemetryErrorType } from './telemetry-error';
 12 | import { logger } from '../utils/logger';
 13 | import { existsSync, readFileSync } from 'fs';
 14 | import { resolve } from 'path';
 15 | import { sanitizeErrorMessageCore } from './error-sanitization-utils';
 16 | 
 17 | export class TelemetryEventTracker {
 18 |   private rateLimiter: TelemetryRateLimiter;
 19 |   private validator: TelemetryEventValidator;
 20 |   private eventQueue: TelemetryEvent[] = [];
 21 |   private workflowQueue: WorkflowTelemetry[] = [];
 22 |   private previousTool?: string;
 23 |   private previousToolTimestamp: number = 0;
 24 |   private performanceMetrics: Map<string, number[]> = new Map();
 25 | 
 26 |   constructor(
 27 |     private getUserId: () => string,
 28 |     private isEnabled: () => boolean
 29 |   ) {
 30 |     this.rateLimiter = new TelemetryRateLimiter();
 31 |     this.validator = new TelemetryEventValidator();
 32 |   }
 33 | 
 34 |   /**
 35 |    * Track a tool usage event
 36 |    */
 37 |   trackToolUsage(toolName: string, success: boolean, duration?: number): void {
 38 |     if (!this.isEnabled()) return;
 39 | 
 40 |     // Check rate limit
 41 |     if (!this.rateLimiter.allow()) {
 42 |       logger.debug(`Rate limited: tool_used event for ${toolName}`);
 43 |       return;
 44 |     }
 45 | 
 46 |     // Track performance metrics
 47 |     if (duration !== undefined) {
 48 |       this.recordPerformanceMetric(toolName, duration);
 49 |     }
 50 | 
 51 |     const event: TelemetryEvent = {
 52 |       user_id: this.getUserId(),
 53 |       event: 'tool_used',
 54 |       properties: {
 55 |         tool: toolName.replace(/[^a-zA-Z0-9_-]/g, '_'),
 56 |         success,
 57 |         duration: duration || 0,
 58 |       }
 59 |     };
 60 | 
 61 |     // Validate and queue
 62 |     const validated = this.validator.validateEvent(event);
 63 |     if (validated) {
 64 |       this.eventQueue.push(validated);
 65 |     }
 66 |   }
 67 | 
 68 |   /**
 69 |    * Track workflow creation
 70 |    */
 71 |   async trackWorkflowCreation(workflow: any, validationPassed: boolean): Promise<void> {
 72 |     if (!this.isEnabled()) return;
 73 | 
 74 |     // Check rate limit
 75 |     if (!this.rateLimiter.allow()) {
 76 |       logger.debug('Rate limited: workflow creation event');
 77 |       return;
 78 |     }
 79 | 
 80 |     // Only store workflows that pass validation
 81 |     if (!validationPassed) {
 82 |       this.trackEvent('workflow_validation_failed', {
 83 |         nodeCount: workflow.nodes?.length || 0,
 84 |       });
 85 |       return;
 86 |     }
 87 | 
 88 |     try {
 89 |       const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
 90 | 
 91 |       const telemetryData: WorkflowTelemetry = {
 92 |         user_id: this.getUserId(),
 93 |         workflow_hash: sanitized.workflowHash,
 94 |         node_count: sanitized.nodeCount,
 95 |         node_types: sanitized.nodeTypes,
 96 |         has_trigger: sanitized.hasTrigger,
 97 |         has_webhook: sanitized.hasWebhook,
 98 |         complexity: sanitized.complexity,
 99 |         sanitized_workflow: {
100 |           nodes: sanitized.nodes,
101 |           connections: sanitized.connections,
102 |         },
103 |       };
104 | 
105 |       // Validate workflow telemetry
106 |       const validated = this.validator.validateWorkflow(telemetryData);
107 |       if (validated) {
108 |         this.workflowQueue.push(validated);
109 | 
110 |         // Also track as event
111 |         this.trackEvent('workflow_created', {
112 |           nodeCount: sanitized.nodeCount,
113 |           nodeTypes: sanitized.nodeTypes.length,
114 |           complexity: sanitized.complexity,
115 |           hasTrigger: sanitized.hasTrigger,
116 |           hasWebhook: sanitized.hasWebhook,
117 |         });
118 |       }
119 |     } catch (error) {
120 |       logger.debug('Failed to track workflow creation:', error);
121 |       throw new TelemetryError(
122 |         TelemetryErrorType.VALIDATION_ERROR,
123 |         'Failed to sanitize workflow',
124 |         { error: error instanceof Error ? error.message : String(error) }
125 |       );
126 |     }
127 |   }
128 | 
129 |   /**
130 |    * Track an error event
131 |    */
132 |   trackError(errorType: string, context: string, toolName?: string, errorMessage?: string): void {
133 |     if (!this.isEnabled()) return;
134 | 
135 |     // Don't rate limit error tracking - we want to see all errors
136 |     this.trackEvent('error_occurred', {
137 |       errorType: this.sanitizeErrorType(errorType),
138 |       context: this.sanitizeContext(context),
139 |       tool: toolName ? toolName.replace(/[^a-zA-Z0-9_-]/g, '_') : undefined,
140 |       error: errorMessage ? this.sanitizeErrorMessage(errorMessage) : undefined,
141 |       // Add environment context for better error analysis
142 |       mcpMode: process.env.MCP_MODE || 'stdio',
143 |       platform: process.platform
144 |     }, false); // Skip rate limiting for errors
145 |   }
146 | 
147 |   /**
148 |    * Track a generic event
149 |    */
150 |   trackEvent(eventName: string, properties: Record<string, any>, checkRateLimit: boolean = true): void {
151 |     if (!this.isEnabled()) return;
152 | 
153 |     // Check rate limit unless explicitly skipped
154 |     if (checkRateLimit && !this.rateLimiter.allow()) {
155 |       logger.debug(`Rate limited: ${eventName} event`);
156 |       return;
157 |     }
158 | 
159 |     const event: TelemetryEvent = {
160 |       user_id: this.getUserId(),
161 |       event: eventName,
162 |       properties,
163 |     };
164 | 
165 |     // Validate and queue
166 |     const validated = this.validator.validateEvent(event);
167 |     if (validated) {
168 |       this.eventQueue.push(validated);
169 |     }
170 |   }
171 | 
172 |   /**
173 |    * Track session start with optional startup tracking data (v2.18.2)
174 |    */
175 |   trackSessionStart(startupData?: {
176 |     durationMs?: number;
177 |     checkpoints?: string[];
178 |     errorCount?: number;
179 |   }): void {
180 |     if (!this.isEnabled()) return;
181 | 
182 |     this.trackEvent('session_start', {
183 |       version: this.getPackageVersion(),
184 |       platform: process.platform,
185 |       arch: process.arch,
186 |       nodeVersion: process.version,
187 |       isDocker: process.env.IS_DOCKER === 'true',
188 |       cloudPlatform: this.detectCloudPlatform(),
189 |       mcpMode: process.env.MCP_MODE || 'stdio',
190 |       // NEW: Startup tracking fields (v2.18.2)
191 |       startupDurationMs: startupData?.durationMs,
192 |       checkpointsPassed: startupData?.checkpoints,
193 |       startupErrorCount: startupData?.errorCount || 0,
194 |     });
195 |   }
196 | 
197 |   /**
198 |    * Track startup completion (v2.18.2)
199 |    * Called after first successful tool call to confirm server is functional
200 |    */
201 |   trackStartupComplete(): void {
202 |     if (!this.isEnabled()) return;
203 | 
204 |     this.trackEvent('startup_completed', {
205 |       version: this.getPackageVersion(),
206 |     });
207 |   }
208 | 
209 |   /**
210 |    * Detect cloud platform from environment variables
211 |    * Returns platform name or null if not in cloud
212 |    */
213 |   private detectCloudPlatform(): string | null {
214 |     if (process.env.RAILWAY_ENVIRONMENT) return 'railway';
215 |     if (process.env.RENDER) return 'render';
216 |     if (process.env.FLY_APP_NAME) return 'fly';
217 |     if (process.env.HEROKU_APP_NAME) return 'heroku';
218 |     if (process.env.AWS_EXECUTION_ENV) return 'aws';
219 |     if (process.env.KUBERNETES_SERVICE_HOST) return 'kubernetes';
220 |     if (process.env.GOOGLE_CLOUD_PROJECT) return 'gcp';
221 |     if (process.env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure';
222 |     return null;
223 |   }
224 | 
225 |   /**
226 |    * Track search queries
227 |    */
228 |   trackSearchQuery(query: string, resultsFound: number, searchType: string): void {
229 |     if (!this.isEnabled()) return;
230 | 
231 |     this.trackEvent('search_query', {
232 |       query: query.substring(0, 100),
233 |       resultsFound,
234 |       searchType,
235 |       hasResults: resultsFound > 0,
236 |       isZeroResults: resultsFound === 0
237 |     });
238 |   }
239 | 
240 |   /**
241 |    * Track validation details
242 |    */
243 |   trackValidationDetails(nodeType: string, errorType: string, details: Record<string, any>): void {
244 |     if (!this.isEnabled()) return;
245 | 
246 |     this.trackEvent('validation_details', {
247 |       nodeType: nodeType.replace(/[^a-zA-Z0-9_.-]/g, '_'),
248 |       errorType: this.sanitizeErrorType(errorType),
249 |       errorCategory: this.categorizeError(errorType),
250 |       details
251 |     });
252 |   }
253 | 
254 |   /**
255 |    * Track tool usage sequences
256 |    */
257 |   trackToolSequence(previousTool: string, currentTool: string, timeDelta: number): void {
258 |     if (!this.isEnabled()) return;
259 | 
260 |     this.trackEvent('tool_sequence', {
261 |       previousTool: previousTool.replace(/[^a-zA-Z0-9_-]/g, '_'),
262 |       currentTool: currentTool.replace(/[^a-zA-Z0-9_-]/g, '_'),
263 |       timeDelta: Math.min(timeDelta, 300000), // Cap at 5 minutes
264 |       isSlowTransition: timeDelta > 10000,
265 |       sequence: `${previousTool}->${currentTool}`
266 |     });
267 |   }
268 | 
269 |   /**
270 |    * Track node configuration patterns
271 |    */
272 |   trackNodeConfiguration(nodeType: string, propertiesSet: number, usedDefaults: boolean): void {
273 |     if (!this.isEnabled()) return;
274 | 
275 |     this.trackEvent('node_configuration', {
276 |       nodeType: nodeType.replace(/[^a-zA-Z0-9_.-]/g, '_'),
277 |       propertiesSet,
278 |       usedDefaults,
279 |       complexity: this.categorizeConfigComplexity(propertiesSet)
280 |     });
281 |   }
282 | 
283 |   /**
284 |    * Track performance metrics
285 |    */
286 |   trackPerformanceMetric(operation: string, duration: number, metadata?: Record<string, any>): void {
287 |     if (!this.isEnabled()) return;
288 | 
289 |     // Record for internal metrics
290 |     this.recordPerformanceMetric(operation, duration);
291 | 
292 |     this.trackEvent('performance_metric', {
293 |       operation: operation.replace(/[^a-zA-Z0-9_-]/g, '_'),
294 |       duration,
295 |       isSlow: duration > 1000,
296 |       isVerySlow: duration > 5000,
297 |       metadata
298 |     });
299 |   }
300 | 
301 |   /**
302 |    * Update tool sequence tracking
303 |    */
304 |   updateToolSequence(toolName: string): void {
305 |     if (this.previousTool) {
306 |       const timeDelta = Date.now() - this.previousToolTimestamp;
307 |       this.trackToolSequence(this.previousTool, toolName, timeDelta);
308 |     }
309 | 
310 |     this.previousTool = toolName;
311 |     this.previousToolTimestamp = Date.now();
312 |   }
313 | 
314 |   /**
315 |    * Get queued events
316 |    */
317 |   getEventQueue(): TelemetryEvent[] {
318 |     return [...this.eventQueue];
319 |   }
320 | 
321 |   /**
322 |    * Get queued workflows
323 |    */
324 |   getWorkflowQueue(): WorkflowTelemetry[] {
325 |     return [...this.workflowQueue];
326 |   }
327 | 
328 |   /**
329 |    * Clear event queue
330 |    */
331 |   clearEventQueue(): void {
332 |     this.eventQueue = [];
333 |   }
334 | 
335 |   /**
336 |    * Clear workflow queue
337 |    */
338 |   clearWorkflowQueue(): void {
339 |     this.workflowQueue = [];
340 |   }
341 | 
342 |   /**
343 |    * Get tracking statistics
344 |    */
345 |   getStats() {
346 |     return {
347 |       rateLimiter: this.rateLimiter.getStats(),
348 |       validator: this.validator.getStats(),
349 |       eventQueueSize: this.eventQueue.length,
350 |       workflowQueueSize: this.workflowQueue.length,
351 |       performanceMetrics: this.getPerformanceStats()
352 |     };
353 |   }
354 | 
355 |   /**
356 |    * Record performance metric internally
357 |    */
358 |   private recordPerformanceMetric(operation: string, duration: number): void {
359 |     if (!this.performanceMetrics.has(operation)) {
360 |       this.performanceMetrics.set(operation, []);
361 |     }
362 | 
363 |     const metrics = this.performanceMetrics.get(operation)!;
364 |     metrics.push(duration);
365 | 
366 |     // Keep only last 100 measurements
367 |     if (metrics.length > 100) {
368 |       metrics.shift();
369 |     }
370 |   }
371 | 
372 |   /**
373 |    * Get performance statistics
374 |    */
375 |   private getPerformanceStats() {
376 |     const stats: Record<string, any> = {};
377 | 
378 |     for (const [operation, durations] of this.performanceMetrics.entries()) {
379 |       if (durations.length === 0) continue;
380 | 
381 |       const sorted = [...durations].sort((a, b) => a - b);
382 |       const sum = sorted.reduce((a, b) => a + b, 0);
383 | 
384 |       stats[operation] = {
385 |         count: sorted.length,
386 |         min: sorted[0],
387 |         max: sorted[sorted.length - 1],
388 |         avg: Math.round(sum / sorted.length),
389 |         p50: sorted[Math.floor(sorted.length * 0.5)],
390 |         p95: sorted[Math.floor(sorted.length * 0.95)],
391 |         p99: sorted[Math.floor(sorted.length * 0.99)]
392 |       };
393 |     }
394 | 
395 |     return stats;
396 |   }
397 | 
398 |   /**
399 |    * Categorize error types
400 |    */
401 |   private categorizeError(errorType: string): string {
402 |     const lowerError = errorType.toLowerCase();
403 |     if (lowerError.includes('type')) return 'type_error';
404 |     if (lowerError.includes('validation')) return 'validation_error';
405 |     if (lowerError.includes('required')) return 'required_field_error';
406 |     if (lowerError.includes('connection')) return 'connection_error';
407 |     if (lowerError.includes('expression')) return 'expression_error';
408 |     return 'other_error';
409 |   }
410 | 
411 |   /**
412 |    * Categorize configuration complexity
413 |    */
414 |   private categorizeConfigComplexity(propertiesSet: number): string {
415 |     if (propertiesSet === 0) return 'defaults_only';
416 |     if (propertiesSet <= 3) return 'simple';
417 |     if (propertiesSet <= 10) return 'moderate';
418 |     return 'complex';
419 |   }
420 | 
421 |   /**
422 |    * Get package version
423 |    */
424 |   private getPackageVersion(): string {
425 |     try {
426 |       const possiblePaths = [
427 |         resolve(__dirname, '..', '..', 'package.json'),
428 |         resolve(process.cwd(), 'package.json'),
429 |         resolve(__dirname, '..', '..', '..', 'package.json')
430 |       ];
431 | 
432 |       for (const packagePath of possiblePaths) {
433 |         if (existsSync(packagePath)) {
434 |           const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
435 |           if (packageJson.version) {
436 |             return packageJson.version;
437 |           }
438 |         }
439 |       }
440 | 
441 |       return 'unknown';
442 |     } catch (error) {
443 |       logger.debug('Failed to get package version:', error);
444 |       return 'unknown';
445 |     }
446 |   }
447 | 
448 |   /**
449 |    * Sanitize error type
450 |    */
451 |   private sanitizeErrorType(errorType: string): string {
452 |     return errorType.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 50);
453 |   }
454 | 
455 |   /**
456 |    * Sanitize context
457 |    */
458 |   private sanitizeContext(context: string): string {
459 |     // Sanitize in a specific order to preserve some structure
460 |     let sanitized = context
461 |       // First replace emails (before URLs eat them)
462 |       .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]')
463 |       // Then replace long keys (32+ chars to match validator)
464 |       .replace(/\b[a-zA-Z0-9_-]{32,}/g, '[KEY]')
465 |       // Finally replace URLs but keep the path structure
466 |       .replace(/(https?:\/\/)([^\s\/]+)(\/[^\s]*)?/gi, (match, protocol, domain, path) => {
467 |         return '[URL]' + (path || '');
468 |       });
469 | 
470 |     // Then truncate if needed
471 |     if (sanitized.length > 100) {
472 |       sanitized = sanitized.substring(0, 100);
473 |     }
474 |     return sanitized;
475 |   }
476 | 
477 |   /**
478 |    * Sanitize error message
479 |    * Now uses shared sanitization core from error-sanitization-utils.ts (v2.18.3)
480 |    * This eliminates code duplication and the ReDoS vulnerability
481 |    */
482 |   private sanitizeErrorMessage(errorMessage: string): string {
483 |     return sanitizeErrorMessageCore(errorMessage);
484 |   }
485 | }
```
Page 21/59FirstPrevNextLast