30 Commits

Author SHA1 Message Date
czlonkowski
6d95786938 2.10.2 2025-08-05 08:09:22 +02:00
czlonkowski
21d4b9b9fb chore: update n8n to v1.105.2 and dependencies
- Updated n8n from 1.104.1 to 1.105.2
- Updated n8n-core from 1.103.1 to 1.104.1
- Updated n8n-workflow from 1.101.0 to 1.102.1
- Updated @n8n/n8n-nodes-langchain from 1.103.1 to 1.104.1
- Rebuilt node database with 534 nodes
- All 1,620 tests passing
- Updated CHANGELOG.md

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-05 08:09:08 +02:00
Romuald Członkowski
035c4a349e Merge pull request #121 from czlonkowski/fix/ci-skip-docs-only-changes
fix: skip CI/CD workflows for documentation-only changes
2025-08-02 22:57:58 +02:00
czlonkowski
08f3d8120d fix: skip CI/CD workflows for documentation-only changes
- Add comprehensive paths-ignore to all workflows to skip runs when only docs are changed
- Standardize pattern ordering across all workflow files
- Fix redundant path configuration in benchmark-pr.yml
- Add support for more documentation file types (*.txt, examples/**, .gitignore, etc.)
- Ensure LICENSE* pattern covers all license file variants

This optimization saves CI/CD minutes and reduces costs by avoiding unnecessary
test runs, Docker builds, and benchmarks for documentation-only commits.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 22:23:15 +02:00
czlonkowski
4b1aaa936d update documentation 2025-08-02 21:56:50 +02:00
czlonkowski
e94bb5479c Fix deploy on Railway button 2025-08-02 21:42:00 +02:00
czlonkowski
1a99e9c6c7 fix: resolve YAML syntax error in release workflow
- Fix GitHub Actions expression in shell script by using env variable
- Prevents YAML parsing error on line 452
- Ensures workflow can execute properly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 21:34:49 +02:00
czlonkowski
7dc938065f fix: resolve YAML syntax error in release workflow
- Fix multiline commit message syntax that was breaking YAML parsing
- Add missing GITHUB_TOKEN environment variable for gh CLI commands
- Simplify commit message to avoid YAML parsing issues

The workflow was failing due to unescaped multiline string in git commit command.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 21:32:27 +02:00
czlonkowski
8022ee1f65 feat: add automated release workflow for npm publishing
- Add release.yml GitHub workflow for automated npm releases
- Add prepare-release.js script for version bumping and changelog
- Add extract-changelog.js for release notes extraction
- Add test-release-automation.js for testing the workflow
- Add documentation for automated releases

This enables automatic npm publishing when tags are pushed,
fixing the issue where releases were created but npm packages
were not published.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 21:14:00 +02:00
Romuald Członkowski
9e71c71698 Merge pull request #120 from czlonkowski/fix/issue-118-mcp-connection-loss
### Fixed
- **Memory Leak in SimpleCache**: Fixed critical memory leak causing MCP server connection loss after several hours (fixes #118)
  - Added proper timer cleanup in `SimpleCache.destroy()` method
  - Updated MCP server shutdown to clean up cache timers
  - Enhanced HTTP server error handling with transport error handlers
  - Fixed event listener cleanup to prevent accumulation
  - Added comprehensive test coverage for memory leak prevention
2025-08-02 15:24:53 +02:00
czlonkowski
df4066022f chore: bump version to 2.10.1 for memory leak fix release
- Updated version in package.json and package.runtime.json
- Updated README version badge
- Moved changelog entry from Unreleased to v2.10.1
- Added version comparison link for v2.10.1

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 15:18:58 +02:00
czlonkowski
7a71c3c3f8 fix: memory leak in SimpleCache causing MCP connection loss (fixes #118)
- Added cleanupTimer property to track setInterval timer
- Implemented destroy() method to clear timer and prevent memory leak
- Updated MCP server shutdown to call cache.destroy()
- Enhanced HTTP server error handling with transport.onerror
- Fixed event listener cleanup to prevent accumulation
- Added comprehensive test coverage for memory leak prevention

This fixes the issue where MCP server would lose connection after
several hours due to timer accumulation causing memory exhaustion.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 14:45:58 +02:00
Romuald Członkowski
3bfad51519 Merge pull request #119 from czlonkowski/ci-cd
feat: Add automated release system with CI/CD pipeline
2025-08-02 13:03:38 +02:00
czlonkowski
907d3846a9 chore: release v2.10.0
- Added automated release system with GitHub Actions
- Implemented CI/CD pipeline for zero-touch releases
- Added security fixes for deprecated actions and vulnerabilities
- Created developer tools for release preparation
- Full documentation in docs/AUTOMATED_RELEASES.md
2025-08-02 12:37:18 +02:00
Romuald Członkowski
6de82cd2b9 Merge pull request #117 from czlonkowski/bugfix/general-fixes
fix: Docker build failures and outdated pre-built images
2025-08-02 12:01:16 +02:00
czlonkowski
6856add177 fix: address code review feedback for Docker consolidation
- Improved GitHub Actions test to verify N8N_MODE environment variable
- Added explanatory comment in docker-compose.n8n.yml
- Added Docker Build Changes section to deployment documentation
- Explains the consolidation benefits and rationale for users
2025-08-02 11:54:33 +02:00
czlonkowski
3eecda4bd5 refactor: consolidate Docker builds by removing redundant Dockerfile.n8n
- Research proved n8n packages are NOT required at runtime for N8N_MODE
- The 'n8n' CMD argument was vestigial and completely ignored by code
- N8N_MODE only affects protocol negotiation, not runtime functionality
- Standard Dockerfile works perfectly with N8N_MODE=true

Benefits:
- Eliminates 500MB+ of unnecessary n8n packages from Docker images
- Reduces build time from 8+ minutes to 1-2 minutes
- Simplifies maintenance with single Dockerfile
- Improves CI/CD reliability

Updated:
- Removed Dockerfile.n8n
- Updated GitHub Actions to use standard Dockerfile
- Fixed docker-compose.n8n.yml to use standard Dockerfile
- Added missing MCP_MODE=http and AUTH_TOKEN env vars
- Updated all documentation references
2025-08-02 11:52:04 +02:00
czlonkowski
1c6bff7d42 fix: add missing axios dependency to runtime dependencies
- The Docker build was failing because axios is used by n8n-api-client.ts
- This dependency was missing from package.runtime.json causing container startup failures
- Fixes the Docker CI/CD pipeline that was stuck at v2.3.0
2025-08-02 11:15:14 +02:00
czlonkowski
8864d6fa5c fix: resolve Docker CI/CD and deployment documentation issues
- Create missing v2.9.1 git tag to trigger Docker builds
- Fix GitHub Actions workflow with proper environment variables
- Add comprehensive deployment documentation updates:
  * Add missing MCP_MODE=http environment variable requirement
  * Clarify Server URL must include /mcp endpoint
  * Add complete environment variables reference table
  * Update all Docker examples with proper variable configuration
  * Add version compatibility warnings for pre-built images
  * Document build-from-source as recommended approach
  * Add comprehensive troubleshooting section with common issues
  * Include systematic debugging steps and diagnostic commands
- Optimize package.runtime.json dependencies for Docker builds
- Ensure both MCP_AUTH_TOKEN and AUTH_TOKEN use same value

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 11:11:57 +02:00
Romuald Członkowski
f6906d7971 Merge pull request #116 from czlonkowski/fix/issue-90-fixed-collection-validation
fix: prevent 'propertyValues[itemName] is not iterable' error (fixes #90)
2025-08-02 10:51:38 +02:00
czlonkowski
296bf76e68 fix: resolve TypeScript errors in test files
- Fixed MCP_MODE type assignment in console-manager.test.ts
- Fixed prototype pollution test TypeScript errors in fixed-collection-validator.test.ts
- All linting checks now pass

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 10:27:45 +02:00
czlonkowski
a2be2b36d5 chore: release v2.9.1
- Bumped version from 2.9.0 to 2.9.1
- Updated version badge in README.md
- Added comprehensive changelog entry documenting fixedCollection validation fixes
- Increased test coverage from 79.95% to 80.16% to meet CI requirements
- Added 50 new tests for fixed-collection-validator and console-manager

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 10:16:03 +02:00
czlonkowski
35b4e77bcd fix: resolve TypeScript errors in fixed-collection-validator tests
- Added type imports and isNodeConfig type guard helper
- Fixed all 'autofix is possibly undefined' errors
- Added proper type guards for accessing properties on union type
- Maintained test logic integrity while ensuring type safety

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 09:35:15 +02:00
czlonkowski
a5c60ddde1 fix: address code review feedback for generic fixedCollection validator
- Fixed node type casing inconsistencies (compareDatasets -> comparedatasets, httpRequest -> httprequest)
- Improved error handling in hasInvalidStructure method with null/array checks
- Replaced all 'any' types with proper TypeScript types (NodeConfig, NodeConfigValue)
- Fixed potential memory leak in getAllPatterns by creating deep copies
- Added circular reference protection using WeakSet in hasInvalidStructure

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 09:20:56 +02:00
czlonkowski
066e7fc668 feat: create generic fixedCollection validation utility
- Add FixedCollectionValidator utility to handle all fixedCollection patterns
- Support validation for 12 different node types including Switch, If, Filter,
  Summarize, Compare Datasets, Sort, Aggregate, Set, HTML, HTTP Request, and Airtable
- Refactor enhanced-config-validator to use the generic utility
- Add comprehensive tests with 19 test cases covering all node types
- Maintain backward compatibility with existing validation behavior

This prevents the 'propertyValues[itemName] is not iterable' error across all
susceptible n8n nodes, not just Switch/If/Filter.
2025-08-02 09:09:30 +02:00
czlonkowski
ff17fbcc0a refactor: optimize fixedCollection validation based on code review
- Replace Math.random() with deterministic index-based output keys
- Remove redundant validation logic in node-specific validators
- Keep validation DRY by checking if fixedCollection errors already exist
- Maintain all functionality while improving performance
2025-08-02 08:21:34 +02:00
czlonkowski
f6c9548839 fix: add validation for fixedCollection structures to prevent 'propertyValues[itemName] is not iterable' error (issue #90)
- Add validateFixedCollectionStructures method to detect invalid nested structures
- Add specific validators for Switch, If, and Filter nodes
- Provide auto-fix suggestions that transform invalid structures to correct ones
- Add comprehensive test coverage with 16 test cases
- Integrate validation into EnhancedConfigValidator and WorkflowValidator

This prevents AI agents from creating workflows that fail to load in n8n UI.
2025-08-02 08:16:58 +02:00
czlonkowski
6b78c19545 fix: resolve issue #90 - prevent 'propertyValues[itemName] is not iterable' error
- Add validation for invalid fixedCollection structures in Switch, If, and Filter nodes
- Detect and prevent nested 'conditions.values' patterns that cause n8n UI crashes
- Support both 'n8n-nodes-base.x' and 'nodes-base.x' node type formats
- Provide auto-fix suggestions for invalid structures
- Add comprehensive test coverage for all edge cases

This prevents AI agents from creating invalid node configurations that break n8n's UI.
2025-08-02 00:42:25 +02:00
Romuald Członkowski
7fbab3ec49 Merge pull request #112 from czlonkowski/feature/n8n-integration
## [2.9.0] - 2025-08-01

### Added
- **n8n Integration with MCP Client Tool Support**: Complete n8n integration enabling n8n-mcp to run as MCP server within n8n workflows
  - Full compatibility with n8n's MCP Client Tool node
  - Dedicated n8n mode (`N8N_MODE=true`) for optimized operation
  - Workflow examples and n8n-friendly tool descriptions
  - Quick deployment script (`deploy/quick-deploy-n8n.sh`) for easy setup
  - Docker configuration specifically for n8n deployment (`Dockerfile.n8n`, `docker-compose.n8n.yml`)
  - Test scripts for n8n integration (`test-n8n-integration.sh`, `test-n8n-mode.sh`)
- **n8n Deployment Documentation**: Comprehensive guide for deploying n8n-MCP with n8n (`docs/N8N_DEPLOYMENT.md`)
  - Local testing instructions using `/scripts/test-n8n-mode.sh`
  - Production deployment with Docker Compose
  - Cloud deployment guide for Hetzner, AWS, and other providers
  - n8n MCP Client Tool setup and configuration
  - Troubleshooting section with common issues and solutions
- **Protocol Version Negotiation**: Intelligent client detection for n8n compatibility
  - Automatically detects n8n clients and uses protocol version 2024-11-05
  - Standard MCP clients get the latest version (2025-03-26)
  - Improves compatibility with n8n's MCP Client Tool node
  - Comprehensive protocol negotiation test suite
- **Comprehensive Parameter Validation**: Enhanced validation for all MCP tools
  - Clear, user-friendly error messages for invalid parameters
  - Numeric parameter conversion and edge case handling
  - 52 new parameter validation tests
  - Consistent error format across all tools
- **Session Management**: Improved session handling with comprehensive test coverage
  - Fixed memory leak potential with async cleanup
  - Better connection close handling
  - Enhanced session management tests
- **Dynamic README Version Badge**: Made version badge update automatically from package.json
  - Added `update-readme-version.js` script
  - Enhanced `sync-runtime-version.js` to update README badges
  - Version badge now stays in sync during publish workflow

### Fixed
- **Docker Build Optimization**: Fixed Dockerfile.n8n using wrong dependencies
  - Now uses `package.runtime.json` instead of full `package.json`
  - Reduces build time from 13+ minutes to 1-2 minutes
  - Fixes ARM64 build failures due to network timeouts
  - Reduces image size from ~1.5GB to ~280MB
- **CI Test Failures**: Resolved Docker entrypoint permission issues
  - Updated tests to accept dynamic UID range (10000-59999)
  - Enhanced lock file creation with better error recovery
  - Fixed TypeScript lint errors in test files
  - Fixed flaky performance tests with deterministic versions
- **Schema Validation Issues**: Fixed n8n nested output format compatibility
  - Added validation for n8n's nested output workaround
  - Fixed schema validation errors with n8n MCP Client Tool
  - Enhanced error sanitization for production environments

### Changed
- **Memory Management**: Improved session cleanup to prevent memory leaks
- **Error Handling**: Enhanced error sanitization for production environments
- **Docker Security**: Using unpredictable UIDs/GIDs (10000-59999 range) for better security
- **CI/CD Configuration**: Made codecov patch coverage informational to prevent CI failures on infrastructure code
- **Test Scripts**: Enhanced with Docker auto-installation and better user experience
  - Added colored output and progress indicators
  - Automatic Docker installation for multiple operating systems
  - n8n API key flow for management tools

### Security
- **Enhanced Docker Security**: Dynamic UID/GID generation for containers
- **Error Sanitization**: Improved error messages to prevent information leakage
- **Permission Handling**: Better permission management for mounted volumes
- **Input Validation**: Comprehensive parameter validation prevents injection attacks
2025-08-02 00:11:44 +02:00
Romuald Członkowski
23327f5dc7 Merge pull request #106 from czlonkowski/fix/docker-config-file-support
fix: add Docker configuration file support (fixes #105)
2025-07-31 18:07:48 +02:00
30 changed files with 8722 additions and 475 deletions

View File

@@ -2,11 +2,19 @@ name: Benchmark PR Comparison
on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'tests/benchmarks/**'
- 'package.json'
- 'vitest.config.benchmark.ts'
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
permissions:
pull-requests: write

View File

@@ -3,8 +3,34 @@ name: Performance Benchmarks
on:
push:
branches: [main, feat/comprehensive-testing-suite]
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
workflow_dispatch:
permissions:

View File

@@ -6,9 +6,35 @@ on:
- main
tags:
- 'v*'
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
pull_request:
branches:
- main
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
workflow_dispatch:
env:
@@ -53,7 +79,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.n8n
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -82,13 +108,16 @@ jobs:
- name: Test Docker image
run: |
# Test that the image starts correctly with N8N_MODE
docker run --rm \
-e N8N_MODE=true \
-e MCP_MODE=http \
-e N8N_API_URL=http://localhost:5678 \
-e N8N_API_KEY=test \
-e MCP_AUTH_TOKEN=test \
-e MCP_AUTH_TOKEN=test-token-minimum-32-chars-long \
-e AUTH_TOKEN=test-token-minimum-32-chars-long \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
node dist/index.js n8n --version
node -e "console.log('N8N_MODE:', process.env.N8N_MODE); process.exit(0);"
- name: Test health endpoint
run: |
@@ -97,9 +126,11 @@ jobs:
--name n8n-mcp-test \
-p 3000:3000 \
-e N8N_MODE=true \
-e MCP_MODE=http \
-e N8N_API_URL=http://localhost:5678 \
-e N8N_API_KEY=test \
-e MCP_AUTH_TOKEN=test \
-e MCP_AUTH_TOKEN=test-token-minimum-32-chars-long \
-e AUTH_TOKEN=test-token-minimum-32-chars-long \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
# Wait for container to start
@@ -108,6 +139,9 @@ jobs:
# Test health endpoint
curl -f http://localhost:3000/health || exit 1
# Test MCP endpoint
curl -f http://localhost:3000/mcp || exit 1
# Cleanup
docker stop n8n-mcp-test
docker rm n8n-mcp-test

View File

@@ -9,23 +9,33 @@ on:
- 'v*'
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- 'LICENSE'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'docs/**'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
pull_request:
branches:
- main
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- 'LICENSE'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'docs/**'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
workflow_dispatch:
env:

526
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,526 @@
name: Automated Release
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package.runtime.json'
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
permissions:
contents: write
packages: write
issues: write
pull-requests: write
# Prevent concurrent releases
concurrency:
group: release
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
detect-version-change:
name: Detect Version Change
runs-on: ubuntu-latest
outputs:
version-changed: ${{ steps.check.outputs.changed }}
new-version: ${{ steps.check.outputs.version }}
previous-version: ${{ steps.check.outputs.previous-version }}
is-prerelease: ${{ steps.check.outputs.is-prerelease }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check for version change
id: check
run: |
# Get current version from package.json
CURRENT_VERSION=$(node -e "console.log(require('./package.json').version)")
# Get previous version from git history safely
PREVIOUS_VERSION=$(git show HEAD~1:package.json 2>/dev/null | node -e "
try {
const data = require('fs').readFileSync(0, 'utf8');
const pkg = JSON.parse(data);
console.log(pkg.version || '0.0.0');
} catch (e) {
console.log('0.0.0');
}
" || echo "0.0.0")
echo "Previous version: $PREVIOUS_VERSION"
echo "Current version: $CURRENT_VERSION"
# Check if version changed
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "previous-version=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT
# Check if it's a prerelease (contains alpha, beta, rc, dev)
if echo "$CURRENT_VERSION" | grep -E "(alpha|beta|rc|dev)" > /dev/null; then
echo "is-prerelease=true" >> $GITHUB_OUTPUT
else
echo "is-prerelease=false" >> $GITHUB_OUTPUT
fi
echo "🎉 Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION"
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "previous-version=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT
echo "is-prerelease=false" >> $GITHUB_OUTPUT
echo " No version change detected"
fi
extract-changelog:
name: Extract Changelog
runs-on: ubuntu-latest
needs: detect-version-change
if: needs.detect-version-change.outputs.version-changed == 'true'
outputs:
release-notes: ${{ steps.extract.outputs.notes }}
has-notes: ${{ steps.extract.outputs.has-notes }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract changelog for version
id: extract
run: |
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
CHANGELOG_FILE="docs/CHANGELOG.md"
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "Changelog file not found at $CHANGELOG_FILE"
echo "has-notes=false" >> $GITHUB_OUTPUT
echo "notes=No changelog entries found for version $VERSION" >> $GITHUB_OUTPUT
exit 0
fi
# Use the extracted changelog script
if NOTES=$(node scripts/extract-changelog.js "$VERSION" "$CHANGELOG_FILE" 2>/dev/null); then
echo "has-notes=true" >> $GITHUB_OUTPUT
# Use heredoc to properly handle multiline content
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> $GITHUB_OUTPUT
echo "✅ Successfully extracted changelog for version $VERSION"
else
echo "has-notes=false" >> $GITHUB_OUTPUT
echo "notes=No changelog entries found for version $VERSION" >> $GITHUB_OUTPUT
echo "⚠️ Could not extract changelog for version $VERSION"
fi
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [detect-version-change, extract-changelog]
if: needs.detect-version-change.outputs.version-changed == 'true'
outputs:
release-id: ${{ steps.create.outputs.id }}
upload-url: ${{ steps.create.outputs.upload_url }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create Git Tag
run: |
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Create annotated tag
git tag -a "v$VERSION" -m "Release v$VERSION"
git push origin "v$VERSION"
- name: Create GitHub Release
id: create
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
IS_PRERELEASE="${{ needs.detect-version-change.outputs.is-prerelease }}"
# Create release body
cat > release_body.md << 'EOF'
# Release v${{ needs.detect-version-change.outputs.new-version }}
${{ needs.extract-changelog.outputs.release-notes }}
---
## Installation
### NPM Package
```bash
# Install globally
npm install -g n8n-mcp
# Or run directly
npx n8n-mcp
```
### Docker
```bash
# Standard image
docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp:v${{ needs.detect-version-change.outputs.new-version }}
# Railway optimized
docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp-railway:v${{ needs.detect-version-change.outputs.new-version }}
```
## Documentation
- [Installation Guide](https://github.com/czlonkowski/n8n-mcp#installation)
- [Docker Deployment](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/DOCKER_README.md)
- [n8n Integration](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/N8N_DEPLOYMENT.md)
- [Complete Changelog](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/CHANGELOG.md)
🤖 *Generated with [Claude Code](https://claude.ai/code)*
EOF
# Create release using gh CLI
if [ "$IS_PRERELEASE" = "true" ]; then
PRERELEASE_FLAG="--prerelease"
else
PRERELEASE_FLAG=""
fi
gh release create "v$VERSION" \
--title "Release v$VERSION" \
--notes-file release_body.md \
$PRERELEASE_FLAG
# Output release info for next jobs
RELEASE_ID=$(gh release view "v$VERSION" --json id --jq '.id')
echo "id=$RELEASE_ID" >> $GITHUB_OUTPUT
echo "upload_url=https://uploads.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets{?name,label}" >> $GITHUB_OUTPUT
build-and-test:
name: Build and Test
runs-on: ubuntu-latest
needs: detect-version-change
if: needs.detect-version-change.outputs.version-changed == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Rebuild database
run: npm run rebuild
- name: Run tests
run: npm test
env:
CI: true
- name: Run type checking
run: npm run typecheck
publish-npm:
name: Publish to NPM
runs-on: ubuntu-latest
needs: [detect-version-change, build-and-test, create-release]
if: needs.detect-version-change.outputs.version-changed == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Rebuild database
run: npm run rebuild
- name: Sync runtime version
run: npm run sync:runtime-version
- name: Prepare package for publishing
run: |
# Create publish directory
PUBLISH_DIR="npm-publish-temp"
rm -rf $PUBLISH_DIR
mkdir -p $PUBLISH_DIR
# Copy necessary files
cp -r dist $PUBLISH_DIR/
cp -r data $PUBLISH_DIR/
cp README.md $PUBLISH_DIR/
cp LICENSE $PUBLISH_DIR/
cp .env.example $PUBLISH_DIR/
# Use runtime package.json as base
cp package.runtime.json $PUBLISH_DIR/package.json
cd $PUBLISH_DIR
# Update package.json with complete metadata
node -e "
const pkg = require('./package.json');
pkg.name = 'n8n-mcp';
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
pkg.bin = { 'n8n-mcp': './dist/mcp/index.js' };
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
pkg.author = 'Romuald Czlonkowski @ www.aiadvisors.pl/en';
pkg.license = 'MIT';
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme';
pkg.files = ['dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
delete pkg.private;
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
"
echo "Package prepared for publishing:"
echo "Name: $(node -e "console.log(require('./package.json').name)")"
echo "Version: $(node -e "console.log(require('./package.json').version)")"
- name: Publish to NPM with retry
uses: nick-invision/retry@v2
with:
timeout_minutes: 5
max_attempts: 3
command: |
cd npm-publish-temp
npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Clean up
if: always()
run: rm -rf npm-publish-temp
build-docker:
name: Build and Push Docker Images
runs-on: ubuntu-latest
needs: [detect-version-change, build-and-test]
if: needs.detect-version-change.outputs.version-changed == 'true'
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: true
- name: Check disk space
run: |
echo "Disk usage before Docker build:"
df -h
# Check available space (require at least 2GB)
AVAILABLE_GB=$(df / --output=avail --block-size=1G | tail -1)
if [ "$AVAILABLE_GB" -lt 2 ]; then
echo "❌ Insufficient disk space: ${AVAILABLE_GB}GB available, 2GB required"
exit 1
fi
echo "✅ Sufficient disk space: ${AVAILABLE_GB}GB available"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for standard image
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},value=v${{ needs.detect-version-change.outputs.new-version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.detect-version-change.outputs.new-version }}
type=semver,pattern={{major}},value=v${{ needs.detect-version-change.outputs.new-version }}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push standard Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for Railway image
id: meta-railway
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-railway
tags: |
type=semver,pattern={{version}},value=v${{ needs.detect-version-change.outputs.new-version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.detect-version-change.outputs.new-version }}
type=semver,pattern={{major}},value=v${{ needs.detect-version-change.outputs.new-version }}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Railway Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.railway
platforms: linux/amd64
push: true
tags: ${{ steps.meta-railway.outputs.tags }}
labels: ${{ steps.meta-railway.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
update-documentation:
name: Update Documentation
runs-on: ubuntu-latest
needs: [detect-version-change, create-release, publish-npm, build-docker]
if: needs.detect-version-change.outputs.version-changed == 'true' && !failure()
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Update version badges in README
run: |
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
# Update README version badges
if [ -f "README.md" ]; then
# Update npm version badge
sed -i.bak "s|npm/v/n8n-mcp/[^)]*|npm/v/n8n-mcp/$VERSION|g" README.md
# Update any other version references
sed -i.bak "s|version-[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*|version-$VERSION|g" README.md
# Clean up backup file
rm -f README.md.bak
echo "✅ Updated version badges in README.md to $VERSION"
fi
- name: Commit documentation updates
env:
VERSION: ${{ needs.detect-version-change.outputs.new-version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git diff --quiet; then
echo "No documentation changes to commit"
else
git add README.md
git commit -m "docs: update version badges to v${VERSION}"
git push
echo "✅ Committed documentation updates"
fi
notify-completion:
name: Notify Release Completion
runs-on: ubuntu-latest
needs: [detect-version-change, create-release, publish-npm, build-docker, update-documentation]
if: always() && needs.detect-version-change.outputs.version-changed == 'true'
steps:
- name: Create release summary
run: |
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/v$VERSION"
echo "## 🎉 Release v$VERSION Published Successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ✅ Completed Tasks:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Check job statuses
if [ "${{ needs.create-release.result }}" = "success" ]; then
echo "- ✅ GitHub Release created: [$RELEASE_URL]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY
else
echo "- ❌ GitHub Release creation failed" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.publish-npm.result }}" = "success" ]; then
echo "- ✅ NPM package published: [npmjs.com/package/n8n-mcp](https://www.npmjs.com/package/n8n-mcp)" >> $GITHUB_STEP_SUMMARY
else
echo "- ❌ NPM publishing failed" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.build-docker.result }}" = "success" ]; then
echo "- ✅ Docker images built and pushed" >> $GITHUB_STEP_SUMMARY
echo " - Standard: \`ghcr.io/czlonkowski/n8n-mcp:v$VERSION\`" >> $GITHUB_STEP_SUMMARY
echo " - Railway: \`ghcr.io/czlonkowski/n8n-mcp-railway:v$VERSION\`" >> $GITHUB_STEP_SUMMARY
else
echo "- ❌ Docker image building failed" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.update-documentation.result }}" = "success" ]; then
echo "- ✅ Documentation updated" >> $GITHUB_STEP_SUMMARY
else
echo "- ⚠️ Documentation update skipped or failed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Installation:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "# NPM" >> $GITHUB_STEP_SUMMARY
echo "npx n8n-mcp" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "# Docker" >> $GITHUB_STEP_SUMMARY
echo "docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp:v$VERSION" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "🎉 Release automation completed for v$VERSION!"

View File

@@ -2,8 +2,34 @@ name: Test Suite
on:
push:
branches: [main, feat/comprehensive-testing-suite]
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'examples/**'
- '.github/FUNDING.yml'
- '.github/ISSUE_TEMPLATE/**'
- '.github/pull_request_template.md'
- '.gitignore'
- 'LICENSE*'
- 'ATTRIBUTION.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
permissions:
contents: read

View File

@@ -1,79 +0,0 @@
# Multi-stage Dockerfile optimized for n8n integration
# Stage 1: Build stage
FROM node:20-alpine AS builder
# Install build dependencies
RUN apk add --no-cache python3 make g++ git
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including dev deps for building)
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Stage 2: Production stage
FROM node:20-alpine
# Install runtime dependencies
RUN apk add --no-cache \
curl \
tini \
&& rm -rf /var/cache/apk/*
# Create non-root user with unpredictable UID/GID
# Using a hash of the build time to generate unpredictable IDs
RUN BUILD_HASH=$(date +%s | sha256sum | head -c 8) && \
UID=$((10000 + 0x${BUILD_HASH} % 50000)) && \
GID=$((10000 + 0x${BUILD_HASH} % 50000)) && \
addgroup -g ${GID} n8n-mcp && \
adduser -u ${UID} -G n8n-mcp -s /bin/sh -D n8n-mcp
# Set working directory
WORKDIR /app
# Copy package files (use runtime-only dependencies)
COPY package.runtime.json package.json
# Install production dependencies only
RUN npm install --production --no-audit --no-fund && \
npm cache clean --force
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/data ./data
# Create necessary directories and set permissions
RUN mkdir -p /app/logs /app/data && \
chown -R n8n-mcp:n8n-mcp /app
# Switch to non-root user
USER n8n-mcp
# Set environment variables for n8n mode
ENV NODE_ENV=production \
N8N_MODE=true \
N8N_API_URL="" \
N8N_API_KEY="" \
PORT=3000
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT}/health || exit 1
# Use tini for proper signal handling
ENTRYPOINT ["/sbin/tini", "--"]
# Start the application in n8n mode
CMD ["node", "dist/index.js", "n8n"]

View File

@@ -2,13 +2,13 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/czlonkowski/n8n-mcp?style=social)](https://github.com/czlonkowski/n8n-mcp)
[![Version](https://img.shields.io/badge/version-2.9.0-blue.svg)](https://github.com/czlonkowski/n8n-mcp)
[![Version](https://img.shields.io/badge/version-2.10.1-blue.svg)](https://github.com/czlonkowski/n8n-mcp)
[![npm version](https://img.shields.io/npm/v/n8n-mcp.svg)](https://www.npmjs.com/package/n8n-mcp)
[![codecov](https://codecov.io/gh/czlonkowski/n8n-mcp/graph/badge.svg?token=YOUR_TOKEN)](https://codecov.io/gh/czlonkowski/n8n-mcp)
[![Tests](https://img.shields.io/badge/tests-1356%20passing-brightgreen.svg)](https://github.com/czlonkowski/n8n-mcp/actions)
[![n8n version](https://img.shields.io/badge/n8n-^1.104.1-orange.svg)](https://github.com/n8n-io/n8n)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fczlonkowski%2Fn8n--mcp-green.svg)](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/VY6UOG?referralCode=n8n-mcp)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 525+ workflow automation nodes.
@@ -296,7 +296,7 @@ Add to Claude Desktop config:
Deploy n8n-MCP to Railway's cloud platform with zero configuration:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/VY6UOG?referralCode=n8n-mcp)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
**Benefits:**
- ☁️ **Instant cloud hosting** - No server setup required
@@ -781,6 +781,26 @@ Contributions are welcome! Please:
3. Run tests (`npm test`)
4. Submit a pull request
### 🚀 For Maintainers: Automated Releases
This project uses automated releases triggered by version changes:
```bash
# Guided release preparation
npm run prepare:release
# Test release automation
npm run test:release-automation
```
The system automatically handles:
- 🏷️ GitHub releases with changelog content
- 📦 NPM package publishing
- 🐳 Multi-platform Docker images
- 📚 Documentation updates
See [Automated Release Guide](./docs/AUTOMATED_RELEASES.md) for complete details.
## 👏 Acknowledgments
- [n8n](https://n8n.io) team for the workflow automation platform

Binary file not shown.

View File

@@ -32,7 +32,7 @@ services:
n8n-mcp:
build:
context: .
dockerfile: Dockerfile.n8n
dockerfile: Dockerfile # Uses standard Dockerfile with N8N_MODE=true env var
image: ghcr.io/${GITHUB_REPOSITORY:-czlonkowski/n8n-mcp}/n8n-mcp:${VERSION:-latest}
container_name: n8n-mcp
restart: unless-stopped
@@ -41,9 +41,11 @@ services:
environment:
- NODE_ENV=production
- N8N_MODE=true
- MCP_MODE=http
- N8N_API_URL=http://n8n:5678
- N8N_API_KEY=${N8N_API_KEY}
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
- AUTH_TOKEN=${MCP_AUTH_TOKEN}
- LOG_LEVEL=${LOG_LEVEL:-info}
volumes:
- ./data:/app/data:ro

384
docs/AUTOMATED_RELEASES.md Normal file
View File

@@ -0,0 +1,384 @@
# Automated Release Process
This document describes the automated release system for n8n-mcp, which handles version detection, changelog parsing, and multi-artifact publishing.
## Overview
The automated release system is triggered when the version in `package.json` is updated and pushed to the main branch. It handles:
- 🏷️ **GitHub Releases**: Creates releases with changelog content
- 📦 **NPM Publishing**: Publishes optimized runtime package
- 🐳 **Docker Images**: Builds and pushes multi-platform images
- 📚 **Documentation**: Updates version badges automatically
## Quick Start
### For Maintainers
Use the prepared release script for a guided experience:
```bash
npm run prepare:release
```
This script will:
1. Prompt for the new version
2. Update `package.json` and `package.runtime.json`
3. Update the changelog
4. Run tests and build
5. Create a git commit
6. Optionally push to trigger the release
### Manual Process
1. **Update the version**:
```bash
# Edit package.json version field
vim package.json
# Sync to runtime package
npm run sync:runtime-version
```
2. **Update the changelog**:
```bash
# Edit docs/CHANGELOG.md
vim docs/CHANGELOG.md
```
3. **Test and commit**:
```bash
# Ensure everything works
npm test
npm run build
npm run rebuild
# Commit changes
git add package.json package.runtime.json docs/CHANGELOG.md
git commit -m "chore: release vX.Y.Z"
git push
```
## Workflow Details
### Version Detection
The workflow monitors pushes to the main branch and detects when `package.json` version changes:
```yaml
paths:
- 'package.json'
- 'package.runtime.json'
```
### Changelog Parsing
Automatically extracts release notes from `docs/CHANGELOG.md` using the version header format:
```markdown
## [2.10.0] - 2025-08-02
### Added
- New feature descriptions
### Changed
- Changed feature descriptions
### Fixed
- Bug fix descriptions
```
### Release Artifacts
#### GitHub Release
- Created with extracted changelog content
- Tagged with `vX.Y.Z` format
- Includes installation instructions
- Links to documentation
#### NPM Package
- Published as `n8n-mcp` on npmjs.com
- Uses runtime-only dependencies (8 packages vs 50+ dev deps)
- Optimized for `npx` usage
- ~50MB vs 1GB+ with dev dependencies
#### Docker Images
- **Standard**: `ghcr.io/czlonkowski/n8n-mcp:vX.Y.Z`
- **Railway**: `ghcr.io/czlonkowski/n8n-mcp-railway:vX.Y.Z`
- Multi-platform: linux/amd64, linux/arm64
- Semantic version tags: `vX.Y.Z`, `vX.Y`, `vX`, `latest`
## Configuration
### Required Secrets
Set these in GitHub repository settings → Secrets:
| Secret | Description | Required |
|--------|-------------|----------|
| `NPM_TOKEN` | NPM authentication token for publishing | ✅ Yes |
| `GITHUB_TOKEN` | Automatically provided by GitHub Actions | ✅ Auto |
### NPM Token Setup
1. Login to [npmjs.com](https://www.npmjs.com)
2. Go to Account Settings → Access Tokens
3. Create a new **Automation** token
4. Add as `NPM_TOKEN` secret in GitHub
## Testing
### Test Release Automation
Validate the release system without triggering a release:
```bash
npm run test:release-automation
```
This checks:
- ✅ File existence and structure
- ✅ Version detection logic
- ✅ Changelog parsing
- ✅ Build process
- ✅ NPM package preparation
- ✅ Docker configuration
- ✅ Workflow syntax
- ✅ Environment setup
### Local Testing
Test individual components:
```bash
# Test version detection
node -e "console.log(require('./package.json').version)"
# Test changelog parsing
node scripts/test-release-automation.js
# Test npm package preparation
npm run prepare:publish
# Test Docker build
docker build -t test-image .
```
## Workflow Jobs
### 1. Version Detection
- Compares current vs previous version in git history
- Determines if it's a prerelease (alpha, beta, rc, dev)
- Outputs version information for other jobs
### 2. Changelog Extraction
- Parses `docs/CHANGELOG.md` for the current version
- Extracts content between version headers
- Provides formatted release notes
### 3. GitHub Release Creation
- Creates annotated git tag
- Creates GitHub release with changelog content
- Handles prerelease flag for alpha/beta versions
### 4. Build and Test
- Installs dependencies
- Runs full test suite
- Builds TypeScript
- Rebuilds node database
- Type checking
### 5. NPM Publishing
- Prepares optimized package structure
- Uses `package.runtime.json` for dependencies
- Publishes to npmjs.com registry
- Automatic cleanup
### 6. Docker Building
- Multi-platform builds (amd64, arm64)
- Two image variants (standard, railway)
- Semantic versioning tags
- GitHub Container Registry
### 7. Documentation Updates
- Updates version badges in README
- Commits documentation changes
- Automatic push back to repository
## Monitoring
### GitHub Actions
Monitor releases at: https://github.com/czlonkowski/n8n-mcp/actions
### Release Status
- **GitHub Releases**: https://github.com/czlonkowski/n8n-mcp/releases
- **NPM Package**: https://www.npmjs.com/package/n8n-mcp
- **Docker Images**: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp
### Notifications
The workflow provides comprehensive summaries:
- ✅ Success notifications with links
- ❌ Failure notifications with error details
- 📊 Artifact information and installation commands
## Troubleshooting
### Common Issues
#### NPM Publishing Fails
```
Error: 401 Unauthorized
```
**Solution**: Check NPM_TOKEN secret is valid and has publishing permissions.
#### Docker Build Fails
```
Error: failed to solve: could not read from registry
```
**Solution**: Check GitHub Container Registry permissions and GITHUB_TOKEN.
#### Changelog Parsing Fails
```
No changelog entries found for version X.Y.Z
```
**Solution**: Ensure changelog follows the correct format:
```markdown
## [X.Y.Z] - YYYY-MM-DD
```
#### Version Detection Fails
```
Version not incremented
```
**Solution**: Ensure new version is greater than the previous version.
### Recovery Steps
#### Failed NPM Publish
1. Check if version was already published
2. If not, manually publish:
```bash
npm run prepare:publish
cd npm-publish-temp
npm publish
```
#### Failed Docker Build
1. Build locally to test:
```bash
docker build -t test-build .
```
2. Re-trigger workflow or push a fix
#### Incomplete Release
1. Delete the created tag if needed:
```bash
git tag -d vX.Y.Z
git push --delete origin vX.Y.Z
```
2. Fix issues and push again
## Security
### Secrets Management
- NPM_TOKEN has limited scope (publish only)
- GITHUB_TOKEN has automatic scoping
- No secrets are logged or exposed
### Package Security
- Runtime package excludes development dependencies
- No build tools or test frameworks in published package
- Minimal attack surface (~50MB vs 1GB+)
### Docker Security
- Multi-stage builds
- Non-root user execution
- Minimal base images
- Security scanning enabled
## Changelog Format
The automated system expects changelog entries in [Keep a Changelog](https://keepachangelog.com/) format:
```markdown
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
- New features for next release
## [2.10.0] - 2025-08-02
### Added
- Automated release system
- Multi-platform Docker builds
### Changed
- Improved version detection
- Enhanced error handling
### Fixed
- Fixed changelog parsing edge cases
- Fixed Docker build optimization
## [2.9.1] - 2025-08-01
...
```
## Version Strategy
### Semantic Versioning
- **MAJOR** (X.0.0): Breaking changes
- **MINOR** (X.Y.0): New features, backward compatible
- **PATCH** (X.Y.Z): Bug fixes, backward compatible
### Prerelease Versions
- **Alpha**: `X.Y.Z-alpha.N` - Early development
- **Beta**: `X.Y.Z-beta.N` - Feature complete, testing
- **RC**: `X.Y.Z-rc.N` - Release candidate
Prerelease versions are automatically detected and marked appropriately.
## Best Practices
### Before Releasing
1. ✅ Run `npm run test:release-automation`
2. ✅ Update changelog with meaningful descriptions
3. ✅ Test locally with `npm test && npm run build`
4. ✅ Review breaking changes
5. ✅ Consider impact on users
### Version Bumping
- Use `npm run prepare:release` for guided process
- Follow semantic versioning strictly
- Document breaking changes clearly
- Consider backward compatibility
### Changelog Writing
- Be specific about changes
- Include migration notes for breaking changes
- Credit contributors
- Use consistent formatting
## Contributing
### For Maintainers
1. Use automated tools: `npm run prepare:release`
2. Follow semantic versioning
3. Update changelog thoroughly
4. Test before releasing
### For Contributors
- Breaking changes require MAJOR version bump
- New features require MINOR version bump
- Bug fixes require PATCH version bump
- Update changelog in PR descriptions
---
🤖 *This automated release system was designed with [Claude Code](https://claude.ai/code)*

View File

@@ -5,6 +5,110 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.10.2] - 2025-08-05
### Updated
- **n8n Dependencies**: Updated to latest versions for compatibility and new features
- n8n: 1.104.1 → 1.105.2
- n8n-core: 1.103.1 → 1.104.1
- n8n-workflow: 1.101.0 → 1.102.1
- @n8n/n8n-nodes-langchain: 1.103.1 → 1.104.1
- **Node Database**: Rebuilt with 534 nodes from updated n8n packages
- **Template Library**: Fetched 499 workflow templates from the last 12 months
- Templates are filtered to include only those created or updated within the past year
- This ensures the template library contains fresh and actively maintained workflows
- All 1,620 tests passing with updated dependencies
## [2.10.1] - 2025-08-02
### Fixed
- **Memory Leak in SimpleCache**: Fixed critical memory leak causing MCP server connection loss after several hours (fixes #118)
- Added proper timer cleanup in `SimpleCache.destroy()` method
- Updated MCP server shutdown to clean up cache timers
- Enhanced HTTP server error handling with transport error handlers
- Fixed event listener cleanup to prevent accumulation
- Added comprehensive test coverage for memory leak prevention
## [2.10.0] - 2025-08-02
### Added
- **Automated Release System**: Complete CI/CD pipeline for automated releases on version bump
- GitHub Actions workflow (`.github/workflows/release.yml`) with 7 coordinated jobs
- Automatic version detection and changelog extraction
- Multi-artifact publishing: GitHub releases, NPM package, Docker images
- Interactive release preparation tool (`npm run prepare:release`)
- Comprehensive release testing tool (`npm run test:release-automation`)
- Full documentation in `docs/AUTOMATED_RELEASES.md`
- Zero-touch releases: version bump → automatic everything
### Security
- **CI/CD Security Enhancements**:
- Replaced deprecated `actions/create-release@v1` with secure `gh` CLI
- Fixed git checkout vulnerability using safe `git show` commands
- Fixed command injection risk using proper argument arrays
- Added concurrency control to prevent simultaneous releases
- Added disk space checks before resource-intensive operations
- Implemented confirmation gates for destructive operations
### Changed
- **Dockerfile Consolidation**: Removed redundant `Dockerfile.n8n` in favor of single optimized `Dockerfile`
- n8n packages are not required at runtime for N8N_MODE functionality
- Standard image works perfectly with `N8N_MODE=true` environment variable
- Reduces build complexity and maintenance overhead
- Image size reduced by 500MB+ (no unnecessary n8n packages)
- Build time improved from 8+ minutes to 1-2 minutes
### Added (CI/CD Features)
- **Developer Tools**:
- `scripts/prepare-release.js`: Interactive guided release tool
- `scripts/test-release-automation.js`: Validates entire release setup
- `scripts/extract-changelog.js`: Modular changelog extraction
- **Release Automation Features**:
- NPM publishing with 3-retry mechanism for network resilience
- Multi-platform Docker builds (amd64, arm64)
- Semantic version validation and prerelease detection
- Automatic documentation badge updates
- Runtime-optimized NPM package (8 deps vs 50+, ~50MB vs 1GB+)
### Fixed
- Fixed missing `axios` dependency in `package.runtime.json` causing Docker build failures
## [2.9.1] - 2025-08-02
### Fixed
- **Fixed Collection Validation**: Fixed critical issue where AI agents created invalid fixedCollection structures causing "propertyValues[itemName] is not iterable" error (fixes #90)
- Created generic `FixedCollectionValidator` utility class that handles 12 different node types
- Validates and auto-fixes common AI-generated patterns for Switch, If, Filter nodes
- Extended support to Summarize, Compare Datasets, Sort, Aggregate, Set, HTML, HTTP Request, and Airtable nodes
- Added comprehensive test coverage with 19 tests for all affected node types
- Provides clear error messages and automatic structure corrections
- **TypeScript Type Safety**: Improved type safety in fixed collection validator
- Replaced all `any` types with proper TypeScript types (`NodeConfig`, `NodeConfigValue`)
- Added type guards for safe property access
- Fixed potential memory leak in `getAllPatterns` by creating deep copies
- Added circular reference protection using `WeakSet` in structure traversal
- **Node Type Normalization**: Fixed inconsistent node type casing
- Normalized `compareDatasets` to `comparedatasets` and `httpRequest` to `httprequest`
- Ensures consistent node type handling across all validation tools
- Maintains backward compatibility with existing workflows
### Enhanced
- **Code Review Improvements**: Addressed all code review feedback
- Made output keys deterministic by removing `Math.random()` usage
- Improved error handling with comprehensive null/undefined/array checks
- Enhanced memory safety with proper object cloning
- Added protection against circular references in configuration objects
### Testing
- **Comprehensive Test Coverage**: Added extensive tests for fixedCollection validation
- 19 tests covering all 12 affected node types
- Tests for edge cases including empty configs, non-object values, and circular references
- Real-world AI agent pattern tests based on actual ChatGPT/Claude generated configs
- Version compatibility tests across all validation profiles
- TypeScript compilation tests ensuring type safety
## [2.9.0] - 2025-08-01
### Added
@@ -994,6 +1098,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Basic n8n and MCP integration
- Core workflow automation features
[2.10.2]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.1...v2.10.2
[2.10.1]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.0...v2.10.1
[2.10.0]: https://github.com/czlonkowski/n8n-mcp/compare/v2.9.1...v2.10.0
[2.9.1]: https://github.com/czlonkowski/n8n-mcp/compare/v2.9.0...v2.9.1
[2.9.0]: https://github.com/czlonkowski/n8n-mcp/compare/v2.8.3...v2.9.0
[2.8.3]: https://github.com/czlonkowski/n8n-mcp/compare/v2.8.2...v2.8.3
[2.8.2]: https://github.com/czlonkowski/n8n-mcp/compare/v2.8.0...v2.8.2
[2.8.0]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.23...v2.8.0
[2.7.23]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.22...v2.7.23
[2.7.22]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.21...v2.7.22
[2.7.21]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.20...v2.7.21
[2.7.20]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.19...v2.7.20

View File

@@ -1,6 +1,6 @@
# n8n-MCP Deployment Guide
This guide covers how to deploy n8n-MCP and connect it to your n8n instance. Whether you're testing locally or deploying to production, we'll show you how to set up n8n-MCP for use with n8n's MCP Client Tool node.
This guide covers how to deploy n8n-MCP and connect it to AI Agent nodes with the standard MCP Client Tool. Whether you're testing locally or deploying to production, we'll show you how to set it up.
## Table of Contents
- [Overview](#overview)
@@ -57,9 +57,11 @@ For development or custom testing:
```bash
# Set environment variables
export N8N_MODE=true
export MCP_MODE=http # Required for HTTP mode
export N8N_API_URL=http://localhost:5678 # Your n8n instance URL
export N8N_API_KEY=your-api-key-here # Your n8n API key
export MCP_AUTH_TOKEN=test-token-minimum-32-chars-long
export AUTH_TOKEN=test-token-minimum-32-chars-long # Same value as MCP_AUTH_TOKEN
export PORT=3001
# Start the server
@@ -71,18 +73,75 @@ npm start
# Check health
curl http://localhost:3001/health
# Check MCP protocol endpoint
# Check MCP protocol endpoint (this is the endpoint n8n connects to)
curl http://localhost:3001/mcp
# Should return: {"protocolVersion":"2024-11-05"} for n8n compatibility
```
## Environment Variables Reference
| Variable | Required | Description | Example Value |
|----------|----------|-------------|---------------|
| `N8N_MODE` | Yes | Enables n8n integration mode | `true` |
| `MCP_MODE` | Yes | Enables HTTP mode for n8n MCP Client | `http` |
| `N8N_API_URL` | Yes* | URL of your n8n instance | `http://localhost:5678` |
| `N8N_API_KEY` | Yes* | n8n API key for workflow management | `n8n_api_xxx...` |
| `MCP_AUTH_TOKEN` | Yes | Authentication token for MCP requests | `secure-random-32-char-token` |
| `AUTH_TOKEN` | Yes | Must match MCP_AUTH_TOKEN | `secure-random-32-char-token` |
| `PORT` | No | Port for the HTTP server | `3000` (default) |
| `LOG_LEVEL` | No | Logging verbosity | `info`, `debug`, `error` |
*Required only for workflow management features. Documentation tools work without these.
## Docker Build Changes (v2.9.2+)
Starting with version 2.9.2, we use a single optimized Dockerfile for all deployments:
- The previous `Dockerfile.n8n` has been removed as redundant
- N8N_MODE functionality is enabled via the `N8N_MODE=true` environment variable
- This reduces image size by 500MB+ and improves build times from 8+ minutes to 1-2 minutes
- All examples now use the standard `Dockerfile`
## Production Deployment
### Same Server as n8n
If you're running n8n-MCP on the same server as your n8n instance:
1. **Using Docker** (Recommended):
### Building from Source (Recommended)
For the latest features and bug fixes, build from source:
```bash
# Clone and build
git clone https://github.com/czlonkowski/n8n-mcp.git
cd n8n-mcp
# Build Docker image
docker build -t n8n-mcp:latest .
# Create a Docker network if n8n uses one
docker network create n8n-net
# Run n8n-MCP container
docker run -d \
--name n8n-mcp \
--network n8n-net \
-p 3000:3000 \
-e N8N_MODE=true \
-e MCP_MODE=http \
-e N8N_API_URL=http://n8n:5678 \
-e N8N_API_KEY=your-n8n-api-key \
-e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
-e AUTH_TOKEN=$(openssl rand -hex 32) \
-e LOG_LEVEL=info \
--restart unless-stopped \
n8n-mcp:latest
```
### Using Pre-built Image (May Be Outdated)
⚠️ **Warning**: Pre-built images may be outdated due to CI/CD synchronization issues. Always check the [GitHub releases](https://github.com/czlonkowski/n8n-mcp/releases) for the latest version.
```bash
# Create a Docker network if n8n uses one
docker network create n8n-net
@@ -93,15 +152,18 @@ docker run -d \
--network n8n-net \
-p 3000:3000 \
-e N8N_MODE=true \
-e MCP_MODE=http \
-e N8N_API_URL=http://n8n:5678 \
-e N8N_API_KEY=your-n8n-api-key \
-e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
-e AUTH_TOKEN=$(openssl rand -hex 32) \
-e LOG_LEVEL=info \
--restart unless-stopped \
ghcr.io/czlonkowski/n8n-mcp:latest
```
2. **Using systemd** (for native installation):
### Using systemd (for native installation)
```bash
# Create service file
sudo cat > /etc/systemd/system/n8n-mcp.service << EOF
@@ -114,9 +176,11 @@ Type=simple
User=nodejs
WorkingDirectory=/opt/n8n-mcp
Environment="N8N_MODE=true"
Environment="MCP_MODE=http"
Environment="N8N_API_URL=http://localhost:5678"
Environment="N8N_API_KEY=your-n8n-api-key"
Environment="MCP_AUTH_TOKEN=your-secure-token"
Environment="MCP_AUTH_TOKEN=your-secure-token-32-chars-min"
Environment="AUTH_TOKEN=your-secure-token-32-chars-min"
Environment="PORT=3000"
ExecStart=/usr/bin/node /opt/n8n-mcp/dist/mcp/index.js
Restart=on-failure
@@ -134,22 +198,56 @@ sudo systemctl start n8n-mcp
Deploy n8n-MCP on a separate server from your n8n instance:
#### Quick Docker Deployment
#### Quick Docker Deployment (Build from Source)
```bash
# On your cloud server (Hetzner, AWS, DigitalOcean, etc.)
# First, clone and build
git clone https://github.com/czlonkowski/n8n-mcp.git
cd n8n-mcp
docker build -t n8n-mcp:latest .
# Generate auth tokens
AUTH_TOKEN=$(openssl rand -hex 32)
echo "Save this AUTH_TOKEN: $AUTH_TOKEN"
# Run the container
docker run -d \
--name n8n-mcp \
-p 3000:3000 \
-e N8N_MODE=true \
-e MCP_MODE=http \
-e N8N_API_URL=https://your-n8n-instance.com \
-e N8N_API_KEY=your-n8n-api-key \
-e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
-e MCP_AUTH_TOKEN=$AUTH_TOKEN \
-e AUTH_TOKEN=$AUTH_TOKEN \
-e LOG_LEVEL=info \
--restart unless-stopped \
n8n-mcp:latest
```
#### Quick Docker Deployment (Pre-built Image)
⚠️ **Warning**: May be outdated. Check [releases](https://github.com/czlonkowski/n8n-mcp/releases) first.
```bash
# Generate auth tokens
AUTH_TOKEN=$(openssl rand -hex 32)
echo "Save this AUTH_TOKEN: $AUTH_TOKEN"
# Run the container
docker run -d \
--name n8n-mcp \
-p 3000:3000 \
-e N8N_MODE=true \
-e MCP_MODE=http \
-e N8N_API_URL=https://your-n8n-instance.com \
-e N8N_API_KEY=your-n8n-api-key \
-e MCP_AUTH_TOKEN=$AUTH_TOKEN \
-e AUTH_TOKEN=$AUTH_TOKEN \
-e LOG_LEVEL=info \
--restart unless-stopped \
ghcr.io/czlonkowski/n8n-mcp:latest
# Save the MCP_AUTH_TOKEN for later use!
```
#### Full Production Setup (Hetzner/AWS/DigitalOcean)
@@ -170,21 +268,32 @@ curl -fsSL https://get.docker.com | sh
```
3. **Deploy n8n-MCP with SSL** (using Caddy for automatic HTTPS):
**Option A: Build from Source (Recommended)**
```bash
# Clone and prepare
git clone https://github.com/czlonkowski/n8n-mcp.git
cd n8n-mcp
# Build local image
docker build -t n8n-mcp:latest .
# Create docker-compose.yml
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
n8n-mcp:
image: ghcr.io/czlonkowski/n8n-mcp:latest
image: n8n-mcp:latest # Using locally built image
container_name: n8n-mcp
restart: unless-stopped
environment:
- N8N_MODE=true
- MCP_MODE=http
- N8N_API_URL=${N8N_API_URL}
- N8N_API_KEY=${N8N_API_KEY}
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
- AUTH_TOKEN=${AUTH_TOKEN}
- PORT=3000
- LOG_LEVEL=info
networks:
@@ -212,7 +321,57 @@ volumes:
caddy_data:
caddy_config:
EOF
```
**Option B: Pre-built Image (May Be Outdated)**
```bash
# Create docker-compose.yml
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
n8n-mcp:
image: ghcr.io/czlonkowski/n8n-mcp:latest
container_name: n8n-mcp
restart: unless-stopped
environment:
- N8N_MODE=true
- MCP_MODE=http
- N8N_API_URL=${N8N_API_URL}
- N8N_API_KEY=${N8N_API_KEY}
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
- AUTH_TOKEN=${AUTH_TOKEN}
- PORT=3000
- LOG_LEVEL=info
networks:
- web
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- web
networks:
web:
driver: bridge
volumes:
caddy_data:
caddy_config:
EOF
```
**Complete Setup (Both Options)**
```bash
# Create Caddyfile
cat > Caddyfile << 'EOF'
mcp.yourdomain.com {
@@ -221,15 +380,17 @@ mcp.yourdomain.com {
EOF
# Create .env file
AUTH_TOKEN=$(openssl rand -hex 32)
cat > .env << EOF
N8N_API_URL=https://your-n8n-instance.com
N8N_API_KEY=your-n8n-api-key-here
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
MCP_AUTH_TOKEN=$AUTH_TOKEN
AUTH_TOKEN=$AUTH_TOKEN
EOF
# Save the MCP_AUTH_TOKEN!
echo "Your MCP_AUTH_TOKEN is:"
grep MCP_AUTH_TOKEN .env
# Save the AUTH_TOKEN!
echo "Your AUTH_TOKEN is: $AUTH_TOKEN"
echo "Save this token - you'll need it in n8n MCP Client Tool configuration"
# Start services
docker compose up -d
@@ -258,16 +419,18 @@ docker compose up -d
2. **Configure the connection**:
```
Server URL:
- Same server: http://localhost:3000
- Docker network: http://n8n-mcp:3000
- Different server: https://mcp.yourdomain.com
Server URL (MUST include /mcp endpoint):
- Same server: http://localhost:3000/mcp
- Docker network: http://n8n-mcp:3000/mcp
- Different server: https://mcp.yourdomain.com/mcp
Auth Token: [Your MCP_AUTH_TOKEN]
Auth Token: [Your MCP_AUTH_TOKEN/AUTH_TOKEN value]
Transport: HTTP Streamable (SSE)
```
⚠️ **Critical**: The Server URL must include the `/mcp` endpoint path. Without this, the connection will fail.
3. **Test the connection** by selecting a simple tool like `list_nodes`
### Available Tools
@@ -324,70 +487,255 @@ You are an n8n workflow expert. Use the MCP tools to:
## Troubleshooting
### Common Configuration Issues
**Missing `MCP_MODE=http` Environment Variable**
- **Symptom**: n8n MCP Client Tool cannot connect, server doesn't respond on `/mcp` endpoint
- **Solution**: Add `MCP_MODE=http` to your environment variables
- **Why**: Without this, the server runs in stdio mode which is incompatible with n8n
**Server URL Missing `/mcp` Endpoint**
- **Symptom**: "Connection refused" or "Invalid response" in n8n MCP Client Tool
- **Solution**: Ensure your Server URL includes `/mcp` (e.g., `http://localhost:3000/mcp`)
- **Why**: n8n connects to the `/mcp` endpoint specifically, not the root URL
**Mismatched Auth Tokens**
- **Symptom**: "Authentication failed" or "Invalid auth token"
- **Solution**: Ensure both `MCP_AUTH_TOKEN` and `AUTH_TOKEN` have the same value
- **Why**: Both variables must match for proper authentication
### Connection Issues
**"Connection refused" in n8n MCP Client Tool**
- Check n8n-MCP is running: `docker ps` or `systemctl status n8n-mcp`
- Verify port is accessible: `curl http://your-server:3000/health`
- Check firewall rules allow port 3000
1. **Check n8n-MCP is running**:
```bash
# Docker
docker ps | grep n8n-mcp
docker logs n8n-mcp --tail 20
**"Invalid auth token"**
- Ensure MCP_AUTH_TOKEN matches exactly (no extra spaces)
- Token must be at least 32 characters long
- Check for special characters that might need escaping
# Systemd
systemctl status n8n-mcp
journalctl -u n8n-mcp --tail 20
```
2. **Verify endpoints are accessible**:
```bash
# Health check (should return status info)
curl http://your-server:3000/health
# MCP endpoint (should return protocol version)
curl http://your-server:3000/mcp
```
3. **Check firewall and networking**:
```bash
# Test port accessibility from n8n server
telnet your-mcp-server 3000
# Check firewall rules (Ubuntu/Debian)
sudo ufw status
# Check if port is bound correctly
netstat -tlnp | grep :3000
```
**"Invalid auth token" or "Authentication failed"**
1. **Verify token format**:
```bash
# Check token length (should be 64 chars for hex-32)
echo $MCP_AUTH_TOKEN | wc -c
# Verify both tokens match
echo "MCP_AUTH_TOKEN: $MCP_AUTH_TOKEN"
echo "AUTH_TOKEN: $AUTH_TOKEN"
```
2. **Common token issues**:
- Token too short (minimum 32 characters)
- Extra whitespace or newlines in token
- Different values for `MCP_AUTH_TOKEN` and `AUTH_TOKEN`
- Special characters not properly escaped in environment files
**"Cannot connect to n8n API"**
- Verify N8N_API_URL is correct (include http:// or https://)
- Check n8n API key is valid and has necessary permissions
- Ensure n8n instance is accessible from n8n-MCP server
1. **Verify n8n configuration**:
```bash
# Test n8n API accessibility
curl -H "X-N8N-API-KEY: your-api-key" \
https://your-n8n-instance.com/api/v1/workflows
```
### Protocol Issues
2. **Common n8n API issues**:
- `N8N_API_URL` missing protocol (http:// or https://)
- n8n API key expired or invalid
- n8n instance not accessible from n8n-MCP server
- n8n API disabled in settings
### Version Compatibility Issues
**"Outdated Docker Image"**
- **Symptom**: Missing features, old bugs, or compatibility issues
- **Solution**: Build from source instead of using pre-built images
- **Check**: Compare your image version with [GitHub releases](https://github.com/czlonkowski/n8n-mcp/releases)
**"Protocol version mismatch"**
- n8n-MCP automatically uses version 2024-11-05 for n8n
- n8n-MCP automatically uses version 2024-11-05 for n8n compatibility
- Update to latest n8n-MCP version if issues persist
- Check `/mcp` endpoint returns correct version
- Verify `/mcp` endpoint returns correct version
**"Schema validation errors"**
- Known issue with n8n's nested output handling
- n8n-MCP includes workarounds
- Enable debug mode to see detailed errors
### Environment Variable Issues
### Debugging
1. **Enable debug mode**:
**Complete Environment Variable Checklist**:
```bash
# Required for all deployments
export N8N_MODE=true # Enables n8n integration
export MCP_MODE=http # Enables HTTP mode for n8n
export MCP_AUTH_TOKEN=your-secure-32-char-token # Auth token
export AUTH_TOKEN=your-secure-32-char-token # Same value as MCP_AUTH_TOKEN
# Required for workflow management features
export N8N_API_URL=https://your-n8n-instance.com # Your n8n URL
export N8N_API_KEY=your-n8n-api-key # Your n8n API key
# Optional
export PORT=3000 # HTTP port (default: 3000)
export LOG_LEVEL=info # Logging level
```
### Docker-Specific Issues
**Container Build Failures**
```bash
# Clear Docker cache and rebuild
docker system prune -f
docker build --no-cache -t n8n-mcp:latest .
```
**Container Runtime Issues**
```bash
# Check container logs for detailed errors
docker logs n8n-mcp -f --timestamps
# Inspect container environment
docker exec n8n-mcp env | grep -E "(N8N|MCP|AUTH)"
# Test container connectivity
docker exec n8n-mcp curl -f http://localhost:3000/health
```
### Network and SSL Issues
**HTTPS/SSL Problems**
```bash
# Test SSL certificate
openssl s_client -connect mcp.yourdomain.com:443
# Check Caddy logs
docker logs caddy -f --tail 50
```
**Docker Network Issues**
```bash
# Check if containers can communicate
docker network ls
docker network inspect bridge
# Test inter-container connectivity
docker exec n8n curl http://n8n-mcp:3000/health
```
### Debugging Steps
1. **Enable comprehensive logging**:
```bash
# For Docker
docker run -d \
--name n8n-mcp \
-e DEBUG_MCP=true \
-e LOG_LEVEL=debug \
-e N8N_MODE=true \
-e MCP_MODE=http \
# ... other settings
# For systemd, add to service file:
Environment="DEBUG_MCP=true"
Environment="LOG_LEVEL=debug"
```
2. **Check logs**:
2. **Test all endpoints systematically**:
```bash
# Docker
docker logs n8n-mcp -f --tail 100
# 1. Health check (basic server functionality)
curl -v http://localhost:3000/health
# Systemd
journalctl -u n8n-mcp -f
```
# 2. MCP protocol endpoint (what n8n connects to)
curl -v http://localhost:3000/mcp
3. **Test endpoints**:
```bash
# Health check
curl http://localhost:3000/health
# Protocol version
curl http://localhost:3000/mcp
# List tools (requires auth)
curl -X POST http://localhost:3000 \
-H "Authorization: Bearer YOUR_MCP_AUTH_TOKEN" \
# 3. Test authentication (if working, returns tools list)
curl -X POST http://localhost:3000/mcp \
-H "Authorization: Bearer YOUR_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
# 4. Test a simple tool (documentation only, no n8n API needed)
curl -X POST http://localhost:3000/mcp \
-H "Authorization: Bearer YOUR_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_database_statistics","arguments":{}},"id":2}'
```
3. **Common log patterns to look for**:
```bash
# Success patterns
grep "Server started" /var/log/n8n-mcp.log
grep "Protocol version" /var/log/n8n-mcp.log
# Error patterns
grep -i "error\|failed\|invalid" /var/log/n8n-mcp.log
grep -i "auth\|token" /var/log/n8n-mcp.log
grep -i "connection\|network" /var/log/n8n-mcp.log
```
### Getting Help
If you're still experiencing issues:
1. **Gather diagnostic information**:
```bash
# System info
docker --version
docker-compose --version
uname -a
# n8n-MCP version
docker exec n8n-mcp node dist/index.js --version
# Environment check
docker exec n8n-mcp env | grep -E "(N8N|MCP|AUTH)" | sort
# Container status
docker ps | grep n8n-mcp
docker stats n8n-mcp --no-stream
```
2. **Create a minimal test setup**:
```bash
# Test with minimal configuration
docker run -d \
--name n8n-mcp-test \
-p 3001:3000 \
-e N8N_MODE=true \
-e MCP_MODE=http \
-e MCP_AUTH_TOKEN=test-token-minimum-32-chars-long \
-e AUTH_TOKEN=test-token-minimum-32-chars-long \
-e LOG_LEVEL=debug \
n8n-mcp:latest
# Test basic functionality
curl http://localhost:3001/health
curl http://localhost:3001/mcp
```
3. **Report issues**: Include the diagnostic information when opening an issue on [GitHub](https://github.com/czlonkowski/n8n-mcp/issues)
## Performance Tips
- **Minimal deployment**: 1 vCPU, 1GB RAM is sufficient
@@ -395,6 +743,48 @@ curl -X POST http://localhost:3000 \
- **Response time**: Average 12ms for queries
- **Caching**: Built-in 15-minute cache for repeated queries
## Railway Deployment for n8n Integration
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
If you're using the **Deploy to Railway** button, you'll need to modify some environment variables since Railway uses a different Docker image (`Dockerfile.railway`).
### Required Environment Variable Changes
When deploying with Railway for n8n integration, add these variables in your Railway dashboard:
1. **Go to Railway dashboard** → Your service → **Variables tab**
2. **Add the following variables**:
```bash
# Required for n8n integration mode
N8N_MODE=true
# Already set by Railway template, but verify:
MCP_MODE=http # Required for HTTP mode
MCP_AUTH_TOKEN=<your-token> # Must match AUTH_TOKEN
AUTH_TOKEN=<your-token> # Same value as MCP_AUTH_TOKEN
# Optional: For workflow management features
N8N_API_URL=https://your-n8n-instance.com
N8N_API_KEY=your-n8n-api-key
```
3. **Save changes** - Railway will automatically redeploy
### Connecting n8n to Railway-deployed n8n-MCP
In your n8n workflow, configure the MCP Client Tool with:
```
Server URL: https://your-app.up.railway.app/mcp
Auth Token: [Your AUTH_TOKEN value]
Transport: HTTP Streamable (SSE)
```
> **Note**: The Railway deployment automatically includes all required dependencies and uses the optimized `Dockerfile.railway` which is compatible with both Claude Desktop and n8n integrations.
For more details on Railway deployment, see our [Railway Deployment Guide](./RAILWAY_DEPLOYMENT.md).
## Next Steps
- Test your setup with the [MCP Client Tool in n8n](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.mcpclienttool/)

View File

@@ -106,7 +106,26 @@ These are automatically set by the Railway template:
| `HOST` | `0.0.0.0` | Listen on all interfaces |
| `PORT` | (Railway provides) | Don't set manually |
### Optional: n8n API Integration
### Optional Variables
| Variable | Default Value | Description |
|----------|--------------|-------------|
| `N8N_MODE` | `false` | Enable n8n integration mode for MCP Client Tool |
| `N8N_API_URL` | - | URL of your n8n instance (for workflow management) |
| `N8N_API_KEY` | - | API key from n8n Settings → API |
### Optional: n8n Integration
#### For n8n MCP Client Tool Integration
To use n8n-MCP with n8n's MCP Client Tool node:
1. **Go to Railway dashboard** → Your service → **Variables**
2. **Add this variable**:
- `N8N_MODE`: Set to `true` to enable n8n integration mode
3. **Save changes** - Railway will redeploy automatically
#### For n8n API Integration (Workflow Management)
To enable workflow management features:

3606
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.9.0",
"version": "2.10.2",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"bin": {
@@ -77,7 +77,9 @@
"sync:runtime-version": "node scripts/sync-runtime-version.js",
"update:readme-version": "node scripts/update-readme-version.js",
"prepare:publish": "./scripts/publish-npm.sh",
"update:all": "./scripts/update-and-publish-prep.sh"
"update:all": "./scripts/update-and-publish-prep.sh",
"test:release-automation": "node scripts/test-release-automation.js",
"prepare:release": "node scripts/prepare-release.js"
},
"repository": {
"type": "git",
@@ -126,12 +128,12 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.2",
"@n8n/n8n-nodes-langchain": "^1.103.1",
"@n8n/n8n-nodes-langchain": "^1.104.1",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"n8n": "^1.104.1",
"n8n-core": "^1.103.1",
"n8n-workflow": "^1.101.0",
"n8n": "^1.105.2",
"n8n-core": "^1.104.1",
"n8n-workflow": "^1.102.1",
"sql.js": "^1.13.0",
"uuid": "^10.0.0"
},

View File

@@ -1,17 +1,15 @@
{
"name": "n8n-mcp-runtime",
"version": "2.9.0",
"version": "2.10.1",
"description": "n8n MCP Server Runtime Dependencies Only",
"private": true,
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.2",
"better-sqlite3": "^11.10.0",
"sql.js": "^1.13.0",
"express": "^5.1.0",
"dotenv": "^16.5.0",
"axios": "^1.7.2",
"zod": "^3.23.8",
"uuid": "^10.0.0"
"sql.js": "^1.13.0",
"uuid": "^10.0.0",
"axios": "^1.7.7"
},
"engines": {
"node": ">=16.0.0"

84
scripts/extract-changelog.js Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
/**
* Extract changelog content for a specific version
* Used by GitHub Actions to extract release notes
*/
const fs = require('fs');
const path = require('path');
function extractChangelog(version, changelogPath) {
try {
if (!fs.existsSync(changelogPath)) {
console.error(`Changelog file not found at ${changelogPath}`);
process.exit(1);
}
const content = fs.readFileSync(changelogPath, 'utf8');
const lines = content.split('\n');
// Find the start of this version's section
const versionHeaderRegex = new RegExp(`^## \\[${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`);
let startIndex = -1;
let endIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (versionHeaderRegex.test(lines[i])) {
startIndex = i;
break;
}
}
if (startIndex === -1) {
console.error(`No changelog entries found for version ${version}`);
process.exit(1);
}
// Find the end of this version's section (next version or end of file)
for (let i = startIndex + 1; i < lines.length; i++) {
if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) {
endIndex = i;
break;
}
}
if (endIndex === -1) {
endIndex = lines.length;
}
// Extract the section content
const sectionLines = lines.slice(startIndex, endIndex);
// Remove the version header and any trailing empty lines
let contentLines = sectionLines.slice(1);
while (contentLines.length > 0 && contentLines[contentLines.length - 1].trim() === '') {
contentLines.pop();
}
if (contentLines.length === 0) {
console.error(`No content found for version ${version}`);
process.exit(1);
}
const releaseNotes = contentLines.join('\n').trim();
// Write to stdout for GitHub Actions
console.log(releaseNotes);
} catch (error) {
console.error(`Error extracting changelog: ${error.message}`);
process.exit(1);
}
}
// Parse command line arguments
const version = process.argv[2];
const changelogPath = process.argv[3];
if (!version || !changelogPath) {
console.error('Usage: extract-changelog.js <version> <changelog-path>');
process.exit(1);
}
extractChangelog(version, changelogPath);

400
scripts/prepare-release.js Executable file
View File

@@ -0,0 +1,400 @@
#!/usr/bin/env node
/**
* Pre-release preparation script
* Validates and prepares everything needed for a successful release
*/
const fs = require('fs');
const path = require('path');
const { execSync, spawnSync } = require('child_process');
const readline = require('readline');
// Color codes
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function success(message) {
log(`${message}`, 'green');
}
function warning(message) {
log(`⚠️ ${message}`, 'yellow');
}
function error(message) {
log(`${message}`, 'red');
}
function info(message) {
log(` ${message}`, 'blue');
}
function header(title) {
log(`\n${'='.repeat(60)}`, 'cyan');
log(`🚀 ${title}`, 'cyan');
log(`${'='.repeat(60)}`, 'cyan');
}
class ReleasePreparation {
constructor() {
this.rootDir = path.resolve(__dirname, '..');
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
}
async askQuestion(question) {
return new Promise((resolve) => {
this.rl.question(question, resolve);
});
}
/**
* Get current version and ask for new version
*/
async getVersionInfo() {
const packageJson = require(path.join(this.rootDir, 'package.json'));
const currentVersion = packageJson.version;
log(`\nCurrent version: ${currentVersion}`, 'blue');
const newVersion = await this.askQuestion('\nEnter new version (e.g., 2.10.0): ');
if (!newVersion || !this.isValidSemver(newVersion)) {
error('Invalid semantic version format');
throw new Error('Invalid version');
}
if (this.compareVersions(newVersion, currentVersion) <= 0) {
error('New version must be greater than current version');
throw new Error('Version not incremented');
}
return { currentVersion, newVersion };
}
/**
* Validate semantic version format (strict semver compliance)
*/
isValidSemver(version) {
// Strict semantic versioning regex
const semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
return semverRegex.test(version);
}
/**
* Compare two semantic versions
*/
compareVersions(v1, v2) {
const parseVersion = (v) => v.split('-')[0].split('.').map(Number);
const [v1Parts, v2Parts] = [parseVersion(v1), parseVersion(v2)];
for (let i = 0; i < 3; i++) {
if (v1Parts[i] > v2Parts[i]) return 1;
if (v1Parts[i] < v2Parts[i]) return -1;
}
return 0;
}
/**
* Update version in package files
*/
updateVersions(newVersion) {
log('\n📝 Updating version in package files...', 'blue');
// Update package.json
const packageJsonPath = path.join(this.rootDir, 'package.json');
const packageJson = require(packageJsonPath);
packageJson.version = newVersion;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
success('Updated package.json');
// Sync to runtime package
try {
execSync('npm run sync:runtime-version', { cwd: this.rootDir, stdio: 'pipe' });
success('Synced package.runtime.json');
} catch (err) {
warning('Could not sync runtime version automatically');
// Manual sync
const runtimeJsonPath = path.join(this.rootDir, 'package.runtime.json');
if (fs.existsSync(runtimeJsonPath)) {
const runtimeJson = require(runtimeJsonPath);
runtimeJson.version = newVersion;
fs.writeFileSync(runtimeJsonPath, JSON.stringify(runtimeJson, null, 2) + '\n');
success('Manually synced package.runtime.json');
}
}
}
/**
* Update changelog
*/
async updateChangelog(newVersion) {
const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md');
if (!fs.existsSync(changelogPath)) {
warning('Changelog file not found, skipping update');
return;
}
log('\n📋 Updating changelog...', 'blue');
const content = fs.readFileSync(changelogPath, 'utf8');
const today = new Date().toISOString().split('T')[0];
// Check if version already exists in changelog
const versionRegex = new RegExp(`^## \\[${newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm');
if (versionRegex.test(content)) {
info(`Version ${newVersion} already exists in changelog`);
return;
}
// Find the Unreleased section
const unreleasedMatch = content.match(/^## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/m);
if (unreleasedMatch) {
const unreleasedContent = unreleasedMatch[1].trim();
if (unreleasedContent) {
log('\nFound content in Unreleased section:', 'blue');
log(unreleasedContent.substring(0, 200) + '...', 'yellow');
const moveContent = await this.askQuestion('\nMove this content to the new version? (y/n): ');
if (moveContent.toLowerCase() === 'y') {
// Move unreleased content to new version
const newVersionSection = `## [${newVersion}] - ${today}\n\n${unreleasedContent}\n\n`;
const updatedContent = content.replace(
/^## \[Unreleased\]\s*\n[\s\S]*?(?=\n## \[)/m,
`## [Unreleased]\n\n${newVersionSection}## [`
);
fs.writeFileSync(changelogPath, updatedContent);
success(`Moved unreleased content to version ${newVersion}`);
} else {
// Just add empty version section
const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`;
const updatedContent = content.replace(
/^## \[Unreleased\]\s*\n/m,
`## [Unreleased]\n\n${newVersionSection}`
);
fs.writeFileSync(changelogPath, updatedContent);
warning(`Added empty version section for ${newVersion} - please fill in the changes`);
}
} else {
// Add empty version section
const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`;
const updatedContent = content.replace(
/^## \[Unreleased\]\s*\n/m,
`## [Unreleased]\n\n${newVersionSection}`
);
fs.writeFileSync(changelogPath, updatedContent);
warning(`Added empty version section for ${newVersion} - please fill in the changes`);
}
} else {
warning('Could not find Unreleased section in changelog');
}
info('Please review and edit the changelog before committing');
}
/**
* Run tests and build
*/
async runChecks() {
log('\n🧪 Running pre-release checks...', 'blue');
try {
// Run tests
log('Running tests...', 'blue');
execSync('npm test', { cwd: this.rootDir, stdio: 'inherit' });
success('All tests passed');
// Run build
log('Building project...', 'blue');
execSync('npm run build', { cwd: this.rootDir, stdio: 'inherit' });
success('Build completed');
// Rebuild database
log('Rebuilding database...', 'blue');
execSync('npm run rebuild', { cwd: this.rootDir, stdio: 'inherit' });
success('Database rebuilt');
// Run type checking
log('Type checking...', 'blue');
execSync('npm run typecheck', { cwd: this.rootDir, stdio: 'inherit' });
success('Type checking passed');
} catch (err) {
error('Pre-release checks failed');
throw err;
}
}
/**
* Create git commit
*/
async createCommit(newVersion) {
log('\n📝 Creating git commit...', 'blue');
try {
// Check git status
const status = execSync('git status --porcelain', {
cwd: this.rootDir,
encoding: 'utf8'
});
if (!status.trim()) {
info('No changes to commit');
return;
}
// Show what will be committed
log('\nFiles to be committed:', 'blue');
execSync('git diff --name-only', { cwd: this.rootDir, stdio: 'inherit' });
const commit = await this.askQuestion('\nCreate commit for release? (y/n): ');
if (commit.toLowerCase() === 'y') {
// Add files
execSync('git add package.json package.runtime.json docs/CHANGELOG.md', {
cwd: this.rootDir,
stdio: 'pipe'
});
// Create commit
const commitMessage = `chore: release v${newVersion}
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>`;
const result = spawnSync('git', ['commit', '-m', commitMessage], {
cwd: this.rootDir,
stdio: 'pipe',
encoding: 'utf8'
});
if (result.error || result.status !== 0) {
throw new Error(`Git commit failed: ${result.stderr || result.error?.message}`);
}
success(`Created commit for v${newVersion}`);
const push = await this.askQuestion('\nPush to trigger release workflow? (y/n): ');
if (push.toLowerCase() === 'y') {
// Add confirmation for destructive operation
warning('\n⚠ DESTRUCTIVE OPERATION WARNING ⚠️');
warning('This will trigger a PUBLIC RELEASE that cannot be undone!');
warning('The following will happen automatically:');
warning('• Create GitHub release with tag');
warning('• Publish package to NPM registry');
warning('• Build and push Docker images');
warning('• Update documentation');
const confirmation = await this.askQuestion('\nType "RELEASE" (all caps) to confirm: ');
if (confirmation === 'RELEASE') {
execSync('git push', { cwd: this.rootDir, stdio: 'inherit' });
success('Pushed to remote repository');
log('\n🎉 Release workflow will be triggered automatically!', 'green');
log('Monitor progress at: https://github.com/czlonkowski/n8n-mcp/actions', 'blue');
} else {
warning('Release cancelled. Commit created but not pushed.');
info('You can push manually later to trigger the release.');
}
} else {
info('Commit created but not pushed. Push manually to trigger release.');
}
}
} catch (err) {
error(`Git operations failed: ${err.message}`);
throw err;
}
}
/**
* Display final instructions
*/
displayInstructions(newVersion) {
header('Release Preparation Complete');
log('📋 What happens next:', 'blue');
log(`1. The GitHub Actions workflow will detect the version change to v${newVersion}`, 'green');
log('2. It will automatically:', 'green');
log(' • Create a GitHub release with changelog content', 'green');
log(' • Publish the npm package', 'green');
log(' • Build and push Docker images', 'green');
log(' • Update documentation badges', 'green');
log('\n🔍 Monitor the release at:', 'blue');
log(' • GitHub Actions: https://github.com/czlonkowski/n8n-mcp/actions', 'blue');
log(' • NPM Package: https://www.npmjs.com/package/n8n-mcp', 'blue');
log(' • Docker Images: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp', 'blue');
log('\n✅ Release preparation completed successfully!', 'green');
}
/**
* Main execution flow
*/
async run() {
try {
header('n8n-MCP Release Preparation');
// Get version information
const { currentVersion, newVersion } = await this.getVersionInfo();
log(`\n🔄 Preparing release: ${currentVersion}${newVersion}`, 'magenta');
// Update versions
this.updateVersions(newVersion);
// Update changelog
await this.updateChangelog(newVersion);
// Run pre-release checks
await this.runChecks();
// Create git commit
await this.createCommit(newVersion);
// Display final instructions
this.displayInstructions(newVersion);
} catch (err) {
error(`Release preparation failed: ${err.message}`);
process.exit(1);
} finally {
this.rl.close();
}
}
}
// Run the script
if (require.main === module) {
const preparation = new ReleasePreparation();
preparation.run().catch(err => {
console.error('Release preparation failed:', err);
process.exit(1);
});
}
module.exports = ReleasePreparation;

View File

@@ -0,0 +1,560 @@
#!/usr/bin/env node
/**
* Test script for release automation
* Validates the release workflow components locally
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Color codes for output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function header(title) {
log(`\n${'='.repeat(60)}`, 'cyan');
log(`🧪 ${title}`, 'cyan');
log(`${'='.repeat(60)}`, 'cyan');
}
function section(title) {
log(`\n📋 ${title}`, 'blue');
log(`${'-'.repeat(40)}`, 'blue');
}
function success(message) {
log(`${message}`, 'green');
}
function warning(message) {
log(`⚠️ ${message}`, 'yellow');
}
function error(message) {
log(`${message}`, 'red');
}
function info(message) {
log(` ${message}`, 'blue');
}
class ReleaseAutomationTester {
constructor() {
this.rootDir = path.resolve(__dirname, '..');
this.errors = [];
this.warnings = [];
}
/**
* Test if required files exist
*/
testFileExistence() {
section('Testing File Existence');
const requiredFiles = [
'package.json',
'package.runtime.json',
'docs/CHANGELOG.md',
'.github/workflows/release.yml',
'scripts/sync-runtime-version.js',
'scripts/publish-npm.sh'
];
for (const file of requiredFiles) {
const filePath = path.join(this.rootDir, file);
if (fs.existsSync(filePath)) {
success(`Found: ${file}`);
} else {
error(`Missing: ${file}`);
this.errors.push(`Missing required file: ${file}`);
}
}
}
/**
* Test version detection logic
*/
testVersionDetection() {
section('Testing Version Detection');
try {
const packageJson = require(path.join(this.rootDir, 'package.json'));
const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json'));
success(`Package.json version: ${packageJson.version}`);
success(`Runtime package version: ${runtimeJson.version}`);
if (packageJson.version === runtimeJson.version) {
success('Version sync: Both versions match');
} else {
warning('Version sync: Versions do not match - run sync:runtime-version');
this.warnings.push('Package versions are not synchronized');
}
// Test semantic version format
const semverRegex = /^\d+\.\d+\.\d+(?:-[\w\.-]+)?(?:\+[\w\.-]+)?$/;
if (semverRegex.test(packageJson.version)) {
success(`Version format: Valid semantic version (${packageJson.version})`);
} else {
error(`Version format: Invalid semantic version (${packageJson.version})`);
this.errors.push('Invalid semantic version format');
}
} catch (err) {
error(`Version detection failed: ${err.message}`);
this.errors.push(`Version detection error: ${err.message}`);
}
}
/**
* Test changelog parsing
*/
testChangelogParsing() {
section('Testing Changelog Parsing');
try {
const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md');
if (!fs.existsSync(changelogPath)) {
error('Changelog file not found');
this.errors.push('Missing changelog file');
return;
}
const changelogContent = fs.readFileSync(changelogPath, 'utf8');
const packageJson = require(path.join(this.rootDir, 'package.json'));
const currentVersion = packageJson.version;
// Check if current version exists in changelog
const versionRegex = new RegExp(`^## \\[${currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm');
if (versionRegex.test(changelogContent)) {
success(`Changelog entry found for version ${currentVersion}`);
// Test extraction logic (simplified version of the GitHub Actions script)
const lines = changelogContent.split('\n');
let startIndex = -1;
let endIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (versionRegex.test(lines[i])) {
startIndex = i;
break;
}
}
if (startIndex !== -1) {
// Find the end of this version's section
for (let i = startIndex + 1; i < lines.length; i++) {
if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) {
endIndex = i;
break;
}
}
if (endIndex === -1) {
endIndex = lines.length;
}
const sectionLines = lines.slice(startIndex + 1, endIndex);
const contentLines = sectionLines.filter(line => line.trim() !== '');
if (contentLines.length > 0) {
success(`Changelog content extracted: ${contentLines.length} lines`);
info(`Preview: ${contentLines[0].substring(0, 100)}...`);
} else {
warning('Changelog section appears to be empty');
this.warnings.push(`Empty changelog section for version ${currentVersion}`);
}
}
} else {
warning(`No changelog entry found for current version ${currentVersion}`);
this.warnings.push(`Missing changelog entry for version ${currentVersion}`);
}
// Check changelog format
if (changelogContent.includes('## [Unreleased]')) {
success('Changelog format: Contains Unreleased section');
} else {
warning('Changelog format: Missing Unreleased section');
}
if (changelogContent.includes('Keep a Changelog')) {
success('Changelog format: Follows Keep a Changelog format');
} else {
warning('Changelog format: Does not reference Keep a Changelog');
}
} catch (err) {
error(`Changelog parsing failed: ${err.message}`);
this.errors.push(`Changelog parsing error: ${err.message}`);
}
}
/**
* Test build process
*/
testBuildProcess() {
section('Testing Build Process');
try {
// Check if dist directory exists
const distPath = path.join(this.rootDir, 'dist');
if (fs.existsSync(distPath)) {
success('Build output: dist directory exists');
// Check for key build files
const keyFiles = [
'dist/index.js',
'dist/mcp/index.js',
'dist/mcp/server.js'
];
for (const file of keyFiles) {
const filePath = path.join(this.rootDir, file);
if (fs.existsSync(filePath)) {
success(`Build file: ${file} exists`);
} else {
warning(`Build file: ${file} missing - run 'npm run build'`);
this.warnings.push(`Missing build file: ${file}`);
}
}
} else {
warning('Build output: dist directory missing - run "npm run build"');
this.warnings.push('Missing build output');
}
// Check database
const dbPath = path.join(this.rootDir, 'data/nodes.db');
if (fs.existsSync(dbPath)) {
const stats = fs.statSync(dbPath);
success(`Database: nodes.db exists (${Math.round(stats.size / 1024 / 1024)}MB)`);
} else {
warning('Database: nodes.db missing - run "npm run rebuild"');
this.warnings.push('Missing database file');
}
} catch (err) {
error(`Build process test failed: ${err.message}`);
this.errors.push(`Build process error: ${err.message}`);
}
}
/**
* Test npm publish preparation
*/
testNpmPublishPrep() {
section('Testing NPM Publish Preparation');
try {
const packageJson = require(path.join(this.rootDir, 'package.json'));
const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json'));
// Check package.json fields
const requiredFields = ['name', 'version', 'description', 'main', 'bin'];
for (const field of requiredFields) {
if (packageJson[field]) {
success(`Package field: ${field} is present`);
} else {
error(`Package field: ${field} is missing`);
this.errors.push(`Missing package.json field: ${field}`);
}
}
// Check runtime dependencies
if (runtimeJson.dependencies) {
const depCount = Object.keys(runtimeJson.dependencies).length;
success(`Runtime dependencies: ${depCount} packages`);
// List key dependencies
const keyDeps = ['@modelcontextprotocol/sdk', 'express', 'sql.js'];
for (const dep of keyDeps) {
if (runtimeJson.dependencies[dep]) {
success(`Key dependency: ${dep} (${runtimeJson.dependencies[dep]})`);
} else {
warning(`Key dependency: ${dep} is missing`);
this.warnings.push(`Missing key dependency: ${dep}`);
}
}
} else {
error('Runtime package has no dependencies');
this.errors.push('Missing runtime dependencies');
}
// Check files array
if (packageJson.files && Array.isArray(packageJson.files)) {
success(`Package files: ${packageJson.files.length} patterns specified`);
info(`Files: ${packageJson.files.join(', ')}`);
} else {
warning('Package files: No files array specified');
this.warnings.push('No files array in package.json');
}
} catch (err) {
error(`NPM publish prep test failed: ${err.message}`);
this.errors.push(`NPM publish prep error: ${err.message}`);
}
}
/**
* Test Docker configuration
*/
testDockerConfig() {
section('Testing Docker Configuration');
try {
const dockerfiles = ['Dockerfile', 'Dockerfile.railway'];
for (const dockerfile of dockerfiles) {
const dockerfilePath = path.join(this.rootDir, dockerfile);
if (fs.existsSync(dockerfilePath)) {
success(`Dockerfile: ${dockerfile} exists`);
const content = fs.readFileSync(dockerfilePath, 'utf8');
// Check for key instructions
if (content.includes('FROM node:')) {
success(`${dockerfile}: Uses Node.js base image`);
} else {
warning(`${dockerfile}: Does not use standard Node.js base image`);
}
if (content.includes('COPY dist')) {
success(`${dockerfile}: Copies build output`);
} else {
warning(`${dockerfile}: May not copy build output correctly`);
}
} else {
warning(`Dockerfile: ${dockerfile} not found`);
this.warnings.push(`Missing Dockerfile: ${dockerfile}`);
}
}
// Check docker-compose files
const composeFiles = ['docker-compose.yml', 'docker-compose.n8n.yml'];
for (const composeFile of composeFiles) {
const composePath = path.join(this.rootDir, composeFile);
if (fs.existsSync(composePath)) {
success(`Docker Compose: ${composeFile} exists`);
} else {
info(`Docker Compose: ${composeFile} not found (optional)`);
}
}
} catch (err) {
error(`Docker config test failed: ${err.message}`);
this.errors.push(`Docker config error: ${err.message}`);
}
}
/**
* Test workflow file syntax
*/
testWorkflowSyntax() {
section('Testing Workflow Syntax');
try {
const workflowPath = path.join(this.rootDir, '.github/workflows/release.yml');
if (!fs.existsSync(workflowPath)) {
error('Release workflow file not found');
this.errors.push('Missing release workflow file');
return;
}
const workflowContent = fs.readFileSync(workflowPath, 'utf8');
// Basic YAML structure checks
if (workflowContent.includes('name: Automated Release')) {
success('Workflow: Has correct name');
} else {
warning('Workflow: Name may be incorrect');
}
if (workflowContent.includes('on:') && workflowContent.includes('push:')) {
success('Workflow: Has push trigger');
} else {
error('Workflow: Missing push trigger');
this.errors.push('Workflow missing push trigger');
}
if (workflowContent.includes('branches: [main]')) {
success('Workflow: Configured for main branch');
} else {
warning('Workflow: May not be configured for main branch');
}
// Check for required jobs
const requiredJobs = [
'detect-version-change',
'extract-changelog',
'create-release',
'publish-npm',
'build-docker'
];
for (const job of requiredJobs) {
if (workflowContent.includes(`${job}:`)) {
success(`Workflow job: ${job} defined`);
} else {
error(`Workflow job: ${job} missing`);
this.errors.push(`Missing workflow job: ${job}`);
}
}
// Check for secrets usage
if (workflowContent.includes('${{ secrets.NPM_TOKEN }}')) {
success('Workflow: NPM_TOKEN secret configured');
} else {
warning('Workflow: NPM_TOKEN secret may be missing');
this.warnings.push('NPM_TOKEN secret may need to be configured');
}
if (workflowContent.includes('${{ secrets.GITHUB_TOKEN }}')) {
success('Workflow: GITHUB_TOKEN secret configured');
} else {
warning('Workflow: GITHUB_TOKEN secret may be missing');
}
} catch (err) {
error(`Workflow syntax test failed: ${err.message}`);
this.errors.push(`Workflow syntax error: ${err.message}`);
}
}
/**
* Test environment and dependencies
*/
testEnvironment() {
section('Testing Environment');
try {
// Check Node.js version
const nodeVersion = process.version;
success(`Node.js version: ${nodeVersion}`);
// Check if npm is available
try {
const npmVersion = execSync('npm --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
success(`NPM version: ${npmVersion}`);
} catch (err) {
error('NPM not available');
this.errors.push('NPM not available');
}
// Check if git is available
try {
const gitVersion = execSync('git --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
success(`Git available: ${gitVersion}`);
} catch (err) {
error('Git not available');
this.errors.push('Git not available');
}
// Check if we're in a git repository
try {
execSync('git rev-parse --git-dir', { stdio: 'pipe' });
success('Git repository: Detected');
// Check current branch
try {
const branch = execSync('git branch --show-current', { encoding: 'utf8', stdio: 'pipe' }).trim();
info(`Current branch: ${branch}`);
} catch (err) {
info('Could not determine current branch');
}
} catch (err) {
warning('Not in a git repository');
this.warnings.push('Not in a git repository');
}
} catch (err) {
error(`Environment test failed: ${err.message}`);
this.errors.push(`Environment error: ${err.message}`);
}
}
/**
* Run all tests
*/
async runAllTests() {
header('Release Automation Test Suite');
info('Testing release automation components...');
this.testFileExistence();
this.testVersionDetection();
this.testChangelogParsing();
this.testBuildProcess();
this.testNpmPublishPrep();
this.testDockerConfig();
this.testWorkflowSyntax();
this.testEnvironment();
// Summary
header('Test Summary');
if (this.errors.length === 0 && this.warnings.length === 0) {
log('🎉 All tests passed! Release automation is ready.', 'green');
} else {
if (this.errors.length > 0) {
log(`\n${this.errors.length} Error(s):`, 'red');
this.errors.forEach(err => log(`${err}`, 'red'));
}
if (this.warnings.length > 0) {
log(`\n⚠️ ${this.warnings.length} Warning(s):`, 'yellow');
this.warnings.forEach(warn => log(`${warn}`, 'yellow'));
}
if (this.errors.length > 0) {
log('\n🔧 Please fix the errors before running the release workflow.', 'red');
process.exit(1);
} else {
log('\n✅ No critical errors found. Warnings should be reviewed but won\'t prevent releases.', 'yellow');
}
}
// Next steps
log('\n📋 Next Steps:', 'cyan');
log('1. Ensure all secrets are configured in GitHub repository settings:', 'cyan');
log(' • NPM_TOKEN (required for npm publishing)', 'cyan');
log(' • GITHUB_TOKEN (automatically available)', 'cyan');
log('\n2. To trigger a release:', 'cyan');
log(' • Update version in package.json', 'cyan');
log(' • Update changelog in docs/CHANGELOG.md', 'cyan');
log(' • Commit and push to main branch', 'cyan');
log('\n3. Monitor the release workflow in GitHub Actions', 'cyan');
return this.errors.length === 0;
}
}
// Run the tests
if (require.main === module) {
const tester = new ReleaseAutomationTester();
tester.runAllTests().catch(err => {
console.error('Test suite failed:', err);
process.exit(1);
});
}
module.exports = ReleaseAutomationTester;

View File

@@ -369,7 +369,7 @@ export class SingleSessionHTTPServer {
}
});
// Set up cleanup handler
// Set up cleanup handlers
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) {
@@ -378,6 +378,17 @@ export class SingleSessionHTTPServer {
}
};
// Handle transport errors to prevent connection drops
transport.onerror = (error: Error) => {
const sid = transport.sessionId;
logger.error('Transport error', { sessionId: sid, error: error.message });
if (sid) {
this.removeSession(sid, 'transport_error').catch(err => {
logger.error('Error during transport error cleanup', { error: err });
});
}
};
// Connect the server to the transport BEFORE handling the request
logger.info('handleRequest: Connecting server to new transport');
await server.connect(transport);
@@ -873,7 +884,7 @@ export class SingleSessionHTTPServer {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
// Only add event listener if the request object supports it (not in test mocks)
if (typeof req.on === 'function') {
req.on('close', () => {
const closeHandler = () => {
if (!res.headersSent && sessionId) {
logger.info('Connection closed before response sent', { sessionId });
// Schedule immediate cleanup if connection closes unexpectedly
@@ -883,11 +894,20 @@ export class SingleSessionHTTPServer {
const timeSinceAccess = Date.now() - metadata.lastAccess.getTime();
// Only remove if it's been inactive for a bit to avoid race conditions
if (timeSinceAccess > 60000) { // 1 minute
this.removeSession(sessionId, 'connection_closed');
this.removeSession(sessionId, 'connection_closed').catch(err => {
logger.error('Error during connection close cleanup', { error: err });
});
}
}
});
}
};
req.on('close', closeHandler);
// Clean up event listener when response ends to prevent memory leaks
res.on('finish', () => {
req.removeListener('close', closeHandler);
});
}

View File

@@ -2538,6 +2538,16 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
async shutdown(): Promise<void> {
logger.info('Shutting down MCP server...');
// Clean up cache timers to prevent memory leaks
if (this.cache) {
try {
this.cache.destroy();
logger.info('Cache timers cleaned up');
} catch (error) {
logger.error('Error cleaning up cache:', error);
}
}
// Close database connection if it exists
if (this.db) {
try {

View File

@@ -7,6 +7,7 @@
import { ConfigValidator, ValidationResult, ValidationError, ValidationWarning } from './config-validator';
import { NodeSpecificValidators, NodeValidationContext } from './node-specific-validators';
import { FixedCollectionValidator } from '../utils/fixed-collection-validator';
export type ValidationMode = 'full' | 'operation' | 'minimal';
export type ValidationProfile = 'strict' | 'runtime' | 'ai-friendly' | 'minimal';
@@ -86,6 +87,9 @@ export class EnhancedConfigValidator extends ConfigValidator {
// Generate next steps based on errors
enhancedResult.nextSteps = this.generateNextSteps(enhancedResult);
// Recalculate validity after all enhancements (crucial for fixedCollection validation)
enhancedResult.valid = enhancedResult.errors.length === 0;
return enhancedResult;
}
@@ -186,6 +190,9 @@ export class EnhancedConfigValidator extends ConfigValidator {
config: Record<string, any>,
result: EnhancedValidationResult
): void {
// First, validate fixedCollection properties for known problematic nodes
this.validateFixedCollectionStructures(nodeType, config, result);
// Create context for node-specific validators
const context: NodeValidationContext = {
config,
@@ -195,8 +202,11 @@ export class EnhancedConfigValidator extends ConfigValidator {
autofix: result.autofix || {}
};
// Normalize node type (handle both 'n8n-nodes-base.x' and 'nodes-base.x' formats)
const normalizedNodeType = nodeType.replace('n8n-nodes-base.', 'nodes-base.');
// Use node-specific validators
switch (nodeType) {
switch (normalizedNodeType) {
case 'nodes-base.slack':
NodeSpecificValidators.validateSlack(context);
this.enhanceSlackValidation(config, result);
@@ -235,6 +245,21 @@ export class EnhancedConfigValidator extends ConfigValidator {
case 'nodes-base.mysql':
NodeSpecificValidators.validateMySQL(context);
break;
case 'nodes-base.switch':
this.validateSwitchNodeStructure(config, result);
break;
case 'nodes-base.if':
this.validateIfNodeStructure(config, result);
break;
case 'nodes-base.filter':
this.validateFilterNodeStructure(config, result);
break;
// Additional nodes handled by FixedCollectionValidator
// No need for specific validators as the generic utility handles them
}
// Update autofix if changes were made
@@ -468,4 +493,129 @@ export class EnhancedConfigValidator extends ConfigValidator {
);
}
}
/**
* Validate fixedCollection structures for known problematic nodes
* This prevents the "propertyValues[itemName] is not iterable" error
*/
private static validateFixedCollectionStructures(
nodeType: string,
config: Record<string, any>,
result: EnhancedValidationResult
): void {
// Use the generic FixedCollectionValidator
const validationResult = FixedCollectionValidator.validate(nodeType, config);
if (!validationResult.isValid) {
// Add errors to the result
for (const error of validationResult.errors) {
result.errors.push({
type: 'invalid_value',
property: error.pattern.split('.')[0], // Get the root property
message: error.message,
fix: error.fix
});
}
// Apply autofix if available
if (validationResult.autofix) {
// For nodes like If/Filter where the entire config might be replaced,
// we need to handle it specially
if (typeof validationResult.autofix === 'object' && !Array.isArray(validationResult.autofix)) {
result.autofix = {
...result.autofix,
...validationResult.autofix
};
} else {
// If the autofix is an array (like for If/Filter nodes), wrap it properly
const firstError = validationResult.errors[0];
if (firstError) {
const rootProperty = firstError.pattern.split('.')[0];
result.autofix = {
...result.autofix,
[rootProperty]: validationResult.autofix
};
}
}
}
}
}
/**
* Validate Switch node structure specifically
*/
private static validateSwitchNodeStructure(
config: Record<string, any>,
result: EnhancedValidationResult
): void {
if (!config.rules) return;
// Skip if already caught by validateFixedCollectionStructures
const hasFixedCollectionError = result.errors.some(e =>
e.property === 'rules' && e.message.includes('propertyValues[itemName] is not iterable')
);
if (hasFixedCollectionError) return;
// Validate rules.values structure if present
if (config.rules.values && Array.isArray(config.rules.values)) {
config.rules.values.forEach((rule: any, index: number) => {
if (!rule.conditions) {
result.warnings.push({
type: 'missing_common',
property: 'rules',
message: `Switch rule ${index + 1} is missing "conditions" property`,
suggestion: 'Each rule in the values array should have a "conditions" property'
});
}
if (!rule.outputKey && rule.renameOutput !== false) {
result.warnings.push({
type: 'missing_common',
property: 'rules',
message: `Switch rule ${index + 1} is missing "outputKey" property`,
suggestion: 'Add "outputKey" to specify which output to use when this rule matches'
});
}
});
}
}
/**
* Validate If node structure specifically
*/
private static validateIfNodeStructure(
config: Record<string, any>,
result: EnhancedValidationResult
): void {
if (!config.conditions) return;
// Skip if already caught by validateFixedCollectionStructures
const hasFixedCollectionError = result.errors.some(e =>
e.property === 'conditions' && e.message.includes('propertyValues[itemName] is not iterable')
);
if (hasFixedCollectionError) return;
// Add any If-node-specific validation here in the future
}
/**
* Validate Filter node structure specifically
*/
private static validateFilterNodeStructure(
config: Record<string, any>,
result: EnhancedValidationResult
): void {
if (!config.conditions) return;
// Skip if already caught by validateFixedCollectionStructures
const hasFixedCollectionError = result.errors.some(e =>
e.property === 'conditions' && e.message.includes('propertyValues[itemName] is not iterable')
);
if (hasFixedCollectionError) return;
// Add any Filter-node-specific validation here in the future
}
}

View File

@@ -0,0 +1,479 @@
/**
* Generic utility for validating and fixing fixedCollection structures in n8n nodes
* Prevents the "propertyValues[itemName] is not iterable" error
*/
// Type definitions for node configurations
export type NodeConfigValue = string | number | boolean | null | undefined | NodeConfig | NodeConfigValue[];
export interface NodeConfig {
[key: string]: NodeConfigValue;
}
export interface FixedCollectionPattern {
nodeType: string;
property: string;
subProperty?: string;
expectedStructure: string;
invalidPatterns: string[];
}
export interface FixedCollectionValidationResult {
isValid: boolean;
errors: Array<{
pattern: string;
message: string;
fix: string;
}>;
autofix?: NodeConfig | NodeConfigValue[];
}
export class FixedCollectionValidator {
/**
* Type guard to check if value is a NodeConfig
*/
private static isNodeConfig(value: NodeConfigValue): value is NodeConfig {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Safely get nested property value
*/
private static getNestedValue(obj: NodeConfig, path: string): NodeConfigValue | undefined {
const parts = path.split('.');
let current: NodeConfigValue = obj;
for (const part of parts) {
if (!this.isNodeConfig(current)) {
return undefined;
}
current = current[part];
}
return current;
}
/**
* Known problematic patterns for various n8n nodes
*/
private static readonly KNOWN_PATTERNS: FixedCollectionPattern[] = [
// Conditional nodes (already fixed)
{
nodeType: 'switch',
property: 'rules',
expectedStructure: 'rules.values array',
invalidPatterns: ['rules.conditions', 'rules.conditions.values']
},
{
nodeType: 'if',
property: 'conditions',
expectedStructure: 'conditions array/object',
invalidPatterns: ['conditions.values']
},
{
nodeType: 'filter',
property: 'conditions',
expectedStructure: 'conditions array/object',
invalidPatterns: ['conditions.values']
},
// New nodes identified by research
{
nodeType: 'summarize',
property: 'fieldsToSummarize',
subProperty: 'values',
expectedStructure: 'fieldsToSummarize.values array',
invalidPatterns: ['fieldsToSummarize.values.values']
},
{
nodeType: 'comparedatasets',
property: 'mergeByFields',
subProperty: 'values',
expectedStructure: 'mergeByFields.values array',
invalidPatterns: ['mergeByFields.values.values']
},
{
nodeType: 'sort',
property: 'sortFieldsUi',
subProperty: 'sortField',
expectedStructure: 'sortFieldsUi.sortField array',
invalidPatterns: ['sortFieldsUi.sortField.values']
},
{
nodeType: 'aggregate',
property: 'fieldsToAggregate',
subProperty: 'fieldToAggregate',
expectedStructure: 'fieldsToAggregate.fieldToAggregate array',
invalidPatterns: ['fieldsToAggregate.fieldToAggregate.values']
},
{
nodeType: 'set',
property: 'fields',
subProperty: 'values',
expectedStructure: 'fields.values array',
invalidPatterns: ['fields.values.values']
},
{
nodeType: 'html',
property: 'extractionValues',
subProperty: 'values',
expectedStructure: 'extractionValues.values array',
invalidPatterns: ['extractionValues.values.values']
},
{
nodeType: 'httprequest',
property: 'body',
subProperty: 'parameters',
expectedStructure: 'body.parameters array',
invalidPatterns: ['body.parameters.values']
},
{
nodeType: 'airtable',
property: 'sort',
subProperty: 'sortField',
expectedStructure: 'sort.sortField array',
invalidPatterns: ['sort.sortField.values']
}
];
/**
* Validate a node configuration for fixedCollection issues
* Includes protection against circular references
*/
static validate(
nodeType: string,
config: NodeConfig
): FixedCollectionValidationResult {
// Early return for non-object configs
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return { isValid: true, errors: [] };
}
const normalizedNodeType = this.normalizeNodeType(nodeType);
const pattern = this.getPatternForNode(normalizedNodeType);
if (!pattern) {
return { isValid: true, errors: [] };
}
const result: FixedCollectionValidationResult = {
isValid: true,
errors: []
};
// Check for invalid patterns
for (const invalidPattern of pattern.invalidPatterns) {
if (this.hasInvalidStructure(config, invalidPattern)) {
result.isValid = false;
result.errors.push({
pattern: invalidPattern,
message: `Invalid structure for nodes-base.${pattern.nodeType} node: found nested "${invalidPattern}" but expected "${pattern.expectedStructure}". This causes "propertyValues[itemName] is not iterable" error in n8n.`,
fix: this.generateFixMessage(pattern)
});
// Generate autofix
if (!result.autofix) {
result.autofix = this.generateAutofix(config, pattern);
}
}
}
return result;
}
/**
* Apply autofix to a configuration
*/
static applyAutofix(
config: NodeConfig,
pattern: FixedCollectionPattern
): NodeConfig | NodeConfigValue[] {
const fixedConfig = this.generateAutofix(config, pattern);
// For If/Filter nodes, the autofix might return just the values array
if (pattern.nodeType === 'if' || pattern.nodeType === 'filter') {
const conditions = config.conditions;
if (conditions && typeof conditions === 'object' && !Array.isArray(conditions) && 'values' in conditions) {
const values = conditions.values;
if (values !== undefined && values !== null &&
(Array.isArray(values) || typeof values === 'object')) {
return values as NodeConfig | NodeConfigValue[];
}
}
}
return fixedConfig;
}
/**
* Normalize node type to handle various formats
*/
private static normalizeNodeType(nodeType: string): string {
return nodeType
.replace('n8n-nodes-base.', '')
.replace('nodes-base.', '')
.replace('@n8n/n8n-nodes-langchain.', '')
.toLowerCase();
}
/**
* Get pattern configuration for a specific node type
*/
private static getPatternForNode(nodeType: string): FixedCollectionPattern | undefined {
return this.KNOWN_PATTERNS.find(p => p.nodeType === nodeType);
}
/**
* Check if configuration has an invalid structure
* Includes circular reference protection
*/
private static hasInvalidStructure(
config: NodeConfig,
pattern: string
): boolean {
const parts = pattern.split('.');
let current: NodeConfigValue = config;
const visited = new WeakSet<object>();
for (const part of parts) {
// Check for null/undefined
if (current === null || current === undefined) {
return false;
}
// Check if it's an object (but not an array for property access)
if (typeof current !== 'object' || Array.isArray(current)) {
return false;
}
// Check for circular reference
if (visited.has(current)) {
return false; // Circular reference detected, invalid structure
}
visited.add(current);
// Check if property exists (using hasOwnProperty to avoid prototype pollution)
if (!Object.prototype.hasOwnProperty.call(current, part)) {
return false;
}
const nextValue = (current as NodeConfig)[part];
if (typeof nextValue !== 'object' || nextValue === null) {
// If we have more parts to traverse but current value is not an object, invalid structure
if (parts.indexOf(part) < parts.length - 1) {
return false;
}
}
current = nextValue as NodeConfig;
}
return true;
}
/**
* Generate a fix message for the specific pattern
*/
private static generateFixMessage(pattern: FixedCollectionPattern): string {
switch (pattern.nodeType) {
case 'switch':
return 'Use: { "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }';
case 'if':
case 'filter':
return 'Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"';
case 'summarize':
return 'Use: { "fieldsToSummarize": { "values": [...] } } not nested values.values';
case 'comparedatasets':
return 'Use: { "mergeByFields": { "values": [...] } } not nested values.values';
case 'sort':
return 'Use: { "sortFieldsUi": { "sortField": [...] } } not sortField.values';
case 'aggregate':
return 'Use: { "fieldsToAggregate": { "fieldToAggregate": [...] } } not fieldToAggregate.values';
case 'set':
return 'Use: { "fields": { "values": [...] } } not nested values.values';
case 'html':
return 'Use: { "extractionValues": { "values": [...] } } not nested values.values';
case 'httprequest':
return 'Use: { "body": { "parameters": [...] } } not parameters.values';
case 'airtable':
return 'Use: { "sort": { "sortField": [...] } } not sortField.values';
default:
return `Use ${pattern.expectedStructure} structure`;
}
}
/**
* Generate autofix for invalid structures
*/
private static generateAutofix(
config: NodeConfig,
pattern: FixedCollectionPattern
): NodeConfig | NodeConfigValue[] {
const fixedConfig = { ...config };
switch (pattern.nodeType) {
case 'switch': {
const rules = config.rules;
if (this.isNodeConfig(rules)) {
const conditions = rules.conditions;
if (this.isNodeConfig(conditions) && 'values' in conditions) {
const values = conditions.values;
fixedConfig.rules = {
values: Array.isArray(values)
? values.map((condition, index) => ({
conditions: condition,
outputKey: `output${index + 1}`
}))
: [{
conditions: values,
outputKey: 'output1'
}]
};
} else if (conditions) {
fixedConfig.rules = {
values: [{
conditions: conditions,
outputKey: 'output1'
}]
};
}
}
break;
}
case 'if':
case 'filter': {
const conditions = config.conditions;
if (this.isNodeConfig(conditions) && 'values' in conditions) {
const values = conditions.values;
if (values !== undefined && values !== null &&
(Array.isArray(values) || typeof values === 'object')) {
return values as NodeConfig | NodeConfigValue[];
}
}
break;
}
case 'summarize': {
const fieldsToSummarize = config.fieldsToSummarize;
if (this.isNodeConfig(fieldsToSummarize)) {
const values = fieldsToSummarize.values;
if (this.isNodeConfig(values) && 'values' in values) {
fixedConfig.fieldsToSummarize = {
values: values.values
};
}
}
break;
}
case 'comparedatasets': {
const mergeByFields = config.mergeByFields;
if (this.isNodeConfig(mergeByFields)) {
const values = mergeByFields.values;
if (this.isNodeConfig(values) && 'values' in values) {
fixedConfig.mergeByFields = {
values: values.values
};
}
}
break;
}
case 'sort': {
const sortFieldsUi = config.sortFieldsUi;
if (this.isNodeConfig(sortFieldsUi)) {
const sortField = sortFieldsUi.sortField;
if (this.isNodeConfig(sortField) && 'values' in sortField) {
fixedConfig.sortFieldsUi = {
sortField: sortField.values
};
}
}
break;
}
case 'aggregate': {
const fieldsToAggregate = config.fieldsToAggregate;
if (this.isNodeConfig(fieldsToAggregate)) {
const fieldToAggregate = fieldsToAggregate.fieldToAggregate;
if (this.isNodeConfig(fieldToAggregate) && 'values' in fieldToAggregate) {
fixedConfig.fieldsToAggregate = {
fieldToAggregate: fieldToAggregate.values
};
}
}
break;
}
case 'set': {
const fields = config.fields;
if (this.isNodeConfig(fields)) {
const values = fields.values;
if (this.isNodeConfig(values) && 'values' in values) {
fixedConfig.fields = {
values: values.values
};
}
}
break;
}
case 'html': {
const extractionValues = config.extractionValues;
if (this.isNodeConfig(extractionValues)) {
const values = extractionValues.values;
if (this.isNodeConfig(values) && 'values' in values) {
fixedConfig.extractionValues = {
values: values.values
};
}
}
break;
}
case 'httprequest': {
const body = config.body;
if (this.isNodeConfig(body)) {
const parameters = body.parameters;
if (this.isNodeConfig(parameters) && 'values' in parameters) {
fixedConfig.body = {
...body,
parameters: parameters.values
};
}
}
break;
}
case 'airtable': {
const sort = config.sort;
if (this.isNodeConfig(sort)) {
const sortField = sort.sortField;
if (this.isNodeConfig(sortField) && 'values' in sortField) {
fixedConfig.sort = {
sortField: sortField.values
};
}
}
break;
}
}
return fixedConfig;
}
/**
* Get all known patterns (for testing and documentation)
* Returns a deep copy to prevent external modifications
*/
static getAllPatterns(): FixedCollectionPattern[] {
return this.KNOWN_PATTERNS.map(pattern => ({
...pattern,
invalidPatterns: [...pattern.invalidPatterns]
}));
}
/**
* Check if a node type is susceptible to fixedCollection issues
*/
static isNodeSusceptible(nodeType: string): boolean {
const normalizedType = this.normalizeNodeType(nodeType);
return this.KNOWN_PATTERNS.some(p => p.nodeType === normalizedType);
}
}

View File

@@ -4,10 +4,11 @@
*/
export class SimpleCache {
private cache = new Map<string, { data: any; expires: number }>();
private cleanupTimer: NodeJS.Timeout | null = null;
constructor() {
// Clean up expired entries every minute
setInterval(() => {
this.cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, item] of this.cache.entries()) {
if (item.expires < now) this.cache.delete(key);
@@ -34,4 +35,16 @@ export class SimpleCache {
clear(): void {
this.cache.clear();
}
/**
* Clean up the cache and stop the cleanup timer
* Essential for preventing memory leaks in long-running servers
*/
destroy(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
this.cache.clear();
}
}

View File

@@ -0,0 +1,450 @@
/**
* Fixed Collection Validation Tests
* Tests for the fix of issue #90: "propertyValues[itemName] is not iterable" error
*
* This ensures AI agents cannot create invalid fixedCollection structures that break n8n UI
*/
import { describe, test, expect } from 'vitest';
import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
describe('FixedCollection Validation', () => {
describe('Switch Node v2/v3 Validation', () => {
test('should detect invalid nested conditions structure', () => {
const invalidConfig = {
rules: {
conditions: {
values: [
{
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
]
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].type).toBe('invalid_value');
expect(result.errors[0].property).toBe('rules');
expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable');
expect(result.errors[0].fix).toContain('{ "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }');
});
test('should detect direct conditions in rules (another invalid pattern)', () => {
const invalidConfig = {
rules: {
conditions: {
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.switch node');
});
test('should provide auto-fix for invalid switch structure', () => {
const invalidConfig = {
rules: {
conditions: {
values: [
{
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
]
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.autofix).toBeDefined();
expect(result.autofix!.rules).toBeDefined();
expect(result.autofix!.rules.values).toBeInstanceOf(Array);
expect(result.autofix!.rules.values).toHaveLength(1);
expect(result.autofix!.rules.values[0]).toHaveProperty('conditions');
expect(result.autofix!.rules.values[0]).toHaveProperty('outputKey');
});
test('should accept valid switch structure', () => {
const validConfig = {
rules: {
values: [
{
conditions: {
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
},
outputKey: 'active'
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
validConfig,
[],
'operation',
'ai-friendly'
);
// Should not have the specific fixedCollection error
const hasFixedCollectionError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasFixedCollectionError).toBe(false);
});
test('should warn about missing outputKey in valid structure', () => {
const configMissingOutputKey = {
rules: {
values: [
{
conditions: {
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
// Missing outputKey
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
configMissingOutputKey,
[],
'operation',
'ai-friendly'
);
const hasOutputKeyWarning = result.warnings.some(w =>
w.message.includes('missing "outputKey" property')
);
expect(hasOutputKeyWarning).toBe(true);
});
});
describe('If Node Validation', () => {
test('should detect invalid nested values structure', () => {
const invalidConfig = {
conditions: {
values: [
{
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.if',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].type).toBe('invalid_value');
expect(result.errors[0].property).toBe('conditions');
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node');
expect(result.errors[0].fix).toBe('Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"');
});
test('should provide auto-fix for invalid if structure', () => {
const invalidConfig = {
conditions: {
values: [
{
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.if',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.autofix).toBeDefined();
expect(result.autofix!.conditions).toEqual(invalidConfig.conditions.values);
});
test('should accept valid if structure', () => {
const validConfig = {
conditions: {
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.if',
validConfig,
[],
'operation',
'ai-friendly'
);
// Should not have the specific structure error
const hasStructureError = result.errors.some(e =>
e.message.includes('should be a filter object/array directly')
);
expect(hasStructureError).toBe(false);
});
});
describe('Filter Node Validation', () => {
test('should detect invalid nested values structure', () => {
const invalidConfig = {
conditions: {
values: [
{
value1: '={{$json.score}}',
operation: 'larger',
value2: 80
}
]
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.filter',
invalidConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].type).toBe('invalid_value');
expect(result.errors[0].property).toBe('conditions');
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.filter node');
});
test('should accept valid filter structure', () => {
const validConfig = {
conditions: {
value1: '={{$json.score}}',
operation: 'larger',
value2: 80
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.filter',
validConfig,
[],
'operation',
'ai-friendly'
);
// Should not have the specific structure error
const hasStructureError = result.errors.some(e =>
e.message.includes('should be a filter object/array directly')
);
expect(hasStructureError).toBe(false);
});
});
describe('Edge Cases', () => {
test('should not validate non-problematic nodes', () => {
const config = {
someProperty: {
conditions: {
values: ['should', 'not', 'trigger', 'validation']
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.httpRequest',
config,
[],
'operation',
'ai-friendly'
);
// Should not have fixedCollection errors for non-problematic nodes
const hasFixedCollectionError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasFixedCollectionError).toBe(false);
});
test('should handle empty config gracefully', () => {
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
{},
[],
'operation',
'ai-friendly'
);
// Should not crash or produce false positives
expect(result).toBeDefined();
expect(result.errors).toBeInstanceOf(Array);
});
test('should handle non-object property values', () => {
const config = {
rules: 'not an object'
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
config,
[],
'operation',
'ai-friendly'
);
// Should not crash on non-object values
expect(result).toBeDefined();
expect(result.errors).toBeInstanceOf(Array);
});
});
describe('Real-world AI Agent Patterns', () => {
test('should catch common ChatGPT/Claude switch patterns', () => {
// This is a pattern commonly generated by AI agents
const aiGeneratedConfig = {
rules: {
conditions: {
values: [
{
"value1": "={{$json.status}}",
"operation": "equals",
"value2": "active"
},
{
"value1": "={{$json.priority}}",
"operation": "equals",
"value2": "high"
}
]
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
aiGeneratedConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable');
// Check auto-fix generates correct structure
expect(result.autofix!.rules.values).toHaveLength(2);
result.autofix!.rules.values.forEach((rule: any) => {
expect(rule).toHaveProperty('conditions');
expect(rule).toHaveProperty('outputKey');
});
});
test('should catch common AI if/filter patterns', () => {
const aiGeneratedIfConfig = {
conditions: {
values: {
"value1": "={{$json.age}}",
"operation": "largerEqual",
"value2": 21
}
}
};
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.if',
aiGeneratedIfConfig,
[],
'operation',
'ai-friendly'
);
expect(result.valid).toBe(false);
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node');
});
});
describe('Version Compatibility', () => {
test('should work across different validation profiles', () => {
const invalidConfig = {
rules: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
};
const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> =
['strict', 'runtime', 'ai-friendly', 'minimal'];
profiles.forEach(profile => {
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.switch',
invalidConfig,
[],
'operation',
profile
);
// All profiles should catch this critical error
const hasCriticalError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,413 @@
/**
* Workflow Fixed Collection Validation Tests
* Tests that workflow validation catches fixedCollection structure errors at the workflow level
*/
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { WorkflowValidator } from '../../../src/services/workflow-validator';
import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
import { NodeRepository } from '../../../src/database/node-repository';
describe('Workflow FixedCollection Validation', () => {
let validator: WorkflowValidator;
let mockNodeRepository: any;
beforeEach(() => {
// Create mock repository that returns basic node info for common nodes
mockNodeRepository = {
getNode: vi.fn().mockImplementation((type: string) => {
const normalizedType = type.replace('n8n-nodes-base.', '').replace('nodes-base.', '');
switch (normalizedType) {
case 'webhook':
return {
nodeType: 'nodes-base.webhook',
displayName: 'Webhook',
properties: [
{ name: 'path', type: 'string', required: true },
{ name: 'httpMethod', type: 'options' }
]
};
case 'switch':
return {
nodeType: 'nodes-base.switch',
displayName: 'Switch',
properties: [
{ name: 'rules', type: 'fixedCollection', required: true }
]
};
case 'if':
return {
nodeType: 'nodes-base.if',
displayName: 'If',
properties: [
{ name: 'conditions', type: 'filter', required: true }
]
};
case 'filter':
return {
nodeType: 'nodes-base.filter',
displayName: 'Filter',
properties: [
{ name: 'conditions', type: 'filter', required: true }
]
};
default:
return null;
}
})
};
validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
});
test('should catch invalid Switch node structure in workflow validation', async () => {
const workflow = {
name: 'Test Workflow with Invalid Switch',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: {
path: 'test-webhook'
}
},
{
id: 'switch',
name: 'Switch',
type: 'n8n-nodes-base.switch',
position: [200, 0] as [number, number],
parameters: {
// This is the problematic structure that causes "propertyValues[itemName] is not iterable"
rules: {
conditions: {
values: [
{
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
]
}
}
}
}
],
connections: {
Webhook: {
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
const switchError = result.errors.find(e => e.nodeId === 'switch');
expect(switchError).toBeDefined();
expect(switchError!.message).toContain('propertyValues[itemName] is not iterable');
expect(switchError!.message).toContain('Invalid structure for nodes-base.switch node');
});
test('should catch invalid If node structure in workflow validation', async () => {
const workflow = {
name: 'Test Workflow with Invalid If',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: {
path: 'test-webhook'
}
},
{
id: 'if',
name: 'If',
type: 'n8n-nodes-base.if',
position: [200, 0] as [number, number],
parameters: {
// This is the problematic structure
conditions: {
values: [
{
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
]
}
}
}
],
connections: {
Webhook: {
main: [[{ node: 'If', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
const ifError = result.errors.find(e => e.nodeId === 'if');
expect(ifError).toBeDefined();
expect(ifError!.message).toContain('Invalid structure for nodes-base.if node');
});
test('should accept valid Switch node structure in workflow validation', async () => {
const workflow = {
name: 'Test Workflow with Valid Switch',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: {
path: 'test-webhook'
}
},
{
id: 'switch',
name: 'Switch',
type: 'n8n-nodes-base.switch',
position: [200, 0] as [number, number],
parameters: {
// This is the correct structure
rules: {
values: [
{
conditions: {
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
},
outputKey: 'active'
}
]
}
}
}
],
connections: {
Webhook: {
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
// Should not have fixedCollection structure errors
const hasFixedCollectionError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasFixedCollectionError).toBe(false);
});
test('should catch multiple fixedCollection errors in a single workflow', async () => {
const workflow = {
name: 'Test Workflow with Multiple Invalid Structures',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: {
path: 'test-webhook'
}
},
{
id: 'switch',
name: 'Switch',
type: 'n8n-nodes-base.switch',
position: [200, 0] as [number, number],
parameters: {
rules: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
}
},
{
id: 'if',
name: 'If',
type: 'n8n-nodes-base.if',
position: [400, 0] as [number, number],
parameters: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
},
{
id: 'filter',
name: 'Filter',
type: 'n8n-nodes-base.filter',
position: [600, 0] as [number, number],
parameters: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
}
],
connections: {
Webhook: {
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
},
Switch: {
main: [
[{ node: 'If', type: 'main', index: 0 }],
[{ node: 'Filter', type: 'main', index: 0 }]
]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThanOrEqual(3); // At least one error for each problematic node
// Check that each problematic node has an error
const switchError = result.errors.find(e => e.nodeId === 'switch');
const ifError = result.errors.find(e => e.nodeId === 'if');
const filterError = result.errors.find(e => e.nodeId === 'filter');
expect(switchError).toBeDefined();
expect(ifError).toBeDefined();
expect(filterError).toBeDefined();
});
test('should provide helpful statistics about fixedCollection errors', async () => {
const workflow = {
name: 'Test Workflow Statistics',
nodes: [
{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [0, 0] as [number, number],
parameters: { path: 'test' }
},
{
id: 'bad-switch',
name: 'Bad Switch',
type: 'n8n-nodes-base.switch',
position: [200, 0] as [number, number],
parameters: {
rules: {
conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] }
}
}
},
{
id: 'good-switch',
name: 'Good Switch',
type: 'n8n-nodes-base.switch',
position: [400, 0] as [number, number],
parameters: {
rules: {
values: [{ conditions: { value1: 'test', operation: 'equals', value2: 'test' }, outputKey: 'out' }]
}
}
}
],
connections: {
Webhook: {
main: [
[{ node: 'Bad Switch', type: 'main', index: 0 }],
[{ node: 'Good Switch', type: 'main', index: 0 }]
]
}
}
};
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
expect(result.statistics.totalNodes).toBe(3);
expect(result.statistics.enabledNodes).toBe(3);
expect(result.valid).toBe(false); // Should be invalid due to the bad switch
// Should have at least one error for the bad switch
const badSwitchError = result.errors.find(e => e.nodeId === 'bad-switch');
expect(badSwitchError).toBeDefined();
// Should not have errors for the good switch or webhook
const goodSwitchError = result.errors.find(e => e.nodeId === 'good-switch');
const webhookError = result.errors.find(e => e.nodeId === 'webhook');
// These might have other validation errors, but not fixedCollection errors
if (goodSwitchError) {
expect(goodSwitchError.message).not.toContain('propertyValues[itemName] is not iterable');
}
if (webhookError) {
expect(webhookError.message).not.toContain('propertyValues[itemName] is not iterable');
}
});
test('should work with different validation profiles', async () => {
const workflow = {
name: 'Test Profile Compatibility',
nodes: [
{
id: 'switch',
name: 'Switch',
type: 'n8n-nodes-base.switch',
position: [0, 0] as [number, number],
parameters: {
rules: {
conditions: {
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
}
}
}
}
],
connections: {}
};
const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> =
['strict', 'runtime', 'ai-friendly', 'minimal'];
for (const profile of profiles) {
const result = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile
});
// All profiles should catch this critical error
const hasCriticalError = result.errors.some(e =>
e.message.includes('propertyValues[itemName] is not iterable')
);
expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true);
expect(result.valid, `Profile ${profile} should mark workflow as invalid`).toBe(false);
}
});
});

View File

@@ -0,0 +1,282 @@
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import { ConsoleManager, consoleManager } from '../../../src/utils/console-manager';
describe('ConsoleManager', () => {
let manager: ConsoleManager;
let originalEnv: string | undefined;
beforeEach(() => {
manager = new ConsoleManager();
originalEnv = process.env.MCP_MODE;
// Reset console methods to originals before each test
manager.restore();
});
afterEach(() => {
// Clean up after each test
manager.restore();
if (originalEnv !== undefined) {
process.env.MCP_MODE = originalEnv as "test" | "http" | "stdio" | undefined;
} else {
delete process.env.MCP_MODE;
}
delete process.env.MCP_REQUEST_ACTIVE;
});
describe('silence method', () => {
test('should silence console methods when in HTTP mode', () => {
process.env.MCP_MODE = 'http';
const originalLog = console.log;
const originalError = console.error;
manager.silence();
expect(console.log).not.toBe(originalLog);
expect(console.error).not.toBe(originalError);
expect(manager.isActive).toBe(true);
expect(process.env.MCP_REQUEST_ACTIVE).toBe('true');
});
test('should not silence when not in HTTP mode', () => {
process.env.MCP_MODE = 'stdio';
const originalLog = console.log;
manager.silence();
expect(console.log).toBe(originalLog);
expect(manager.isActive).toBe(false);
});
test('should not silence if already silenced', () => {
process.env.MCP_MODE = 'http';
manager.silence();
const firstSilencedLog = console.log;
manager.silence(); // Call again
expect(console.log).toBe(firstSilencedLog);
expect(manager.isActive).toBe(true);
});
test('should silence all console methods', () => {
process.env.MCP_MODE = 'http';
const originalMethods = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
trace: console.trace
};
manager.silence();
Object.values(originalMethods).forEach(originalMethod => {
const currentMethod = Object.values(console).find(method => method === originalMethod);
expect(currentMethod).toBeUndefined();
});
});
});
describe('restore method', () => {
test('should restore console methods after silencing', () => {
process.env.MCP_MODE = 'http';
const originalLog = console.log;
const originalError = console.error;
manager.silence();
expect(console.log).not.toBe(originalLog);
manager.restore();
expect(console.log).toBe(originalLog);
expect(console.error).toBe(originalError);
expect(manager.isActive).toBe(false);
expect(process.env.MCP_REQUEST_ACTIVE).toBe('false');
});
test('should not restore if not silenced', () => {
const originalLog = console.log;
manager.restore(); // Call without silencing first
expect(console.log).toBe(originalLog);
expect(manager.isActive).toBe(false);
});
test('should restore all console methods', () => {
process.env.MCP_MODE = 'http';
const originalMethods = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
trace: console.trace
};
manager.silence();
manager.restore();
expect(console.log).toBe(originalMethods.log);
expect(console.error).toBe(originalMethods.error);
expect(console.warn).toBe(originalMethods.warn);
expect(console.info).toBe(originalMethods.info);
expect(console.debug).toBe(originalMethods.debug);
expect(console.trace).toBe(originalMethods.trace);
});
});
describe('wrapOperation method', () => {
test('should wrap synchronous operations', async () => {
process.env.MCP_MODE = 'http';
const testValue = 'test-result';
const operation = vi.fn(() => testValue);
const result = await manager.wrapOperation(operation);
expect(result).toBe(testValue);
expect(operation).toHaveBeenCalledOnce();
expect(manager.isActive).toBe(false); // Should be restored after operation
});
test('should wrap asynchronous operations', async () => {
process.env.MCP_MODE = 'http';
const testValue = 'async-result';
const operation = vi.fn(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return testValue;
});
const result = await manager.wrapOperation(operation);
expect(result).toBe(testValue);
expect(operation).toHaveBeenCalledOnce();
expect(manager.isActive).toBe(false); // Should be restored after operation
});
test('should restore console even if synchronous operation throws', async () => {
process.env.MCP_MODE = 'http';
const error = new Error('test error');
const operation = vi.fn(() => {
throw error;
});
await expect(manager.wrapOperation(operation)).rejects.toThrow('test error');
expect(manager.isActive).toBe(false); // Should be restored even after error
});
test('should restore console even if async operation throws', async () => {
process.env.MCP_MODE = 'http';
const error = new Error('async test error');
const operation = vi.fn(async () => {
throw error;
});
await expect(manager.wrapOperation(operation)).rejects.toThrow('async test error');
expect(manager.isActive).toBe(false); // Should be restored even after error
});
test('should handle promise rejection properly', async () => {
process.env.MCP_MODE = 'http';
const error = new Error('promise rejection');
const operation = vi.fn(() => Promise.reject(error));
await expect(manager.wrapOperation(operation)).rejects.toThrow('promise rejection');
expect(manager.isActive).toBe(false); // Should be restored even after rejection
});
});
describe('isActive getter', () => {
test('should return false initially', () => {
expect(manager.isActive).toBe(false);
});
test('should return true when silenced', () => {
process.env.MCP_MODE = 'http';
manager.silence();
expect(manager.isActive).toBe(true);
});
test('should return false after restore', () => {
process.env.MCP_MODE = 'http';
manager.silence();
manager.restore();
expect(manager.isActive).toBe(false);
});
});
describe('Singleton instance', () => {
test('should export a singleton instance', () => {
expect(consoleManager).toBeInstanceOf(ConsoleManager);
});
test('should work with singleton instance', () => {
process.env.MCP_MODE = 'http';
const originalLog = console.log;
consoleManager.silence();
expect(console.log).not.toBe(originalLog);
expect(consoleManager.isActive).toBe(true);
consoleManager.restore();
expect(console.log).toBe(originalLog);
expect(consoleManager.isActive).toBe(false);
});
});
describe('Edge cases', () => {
test('should handle undefined MCP_MODE', () => {
delete process.env.MCP_MODE;
const originalLog = console.log;
manager.silence();
expect(console.log).toBe(originalLog);
expect(manager.isActive).toBe(false);
});
test('should handle empty MCP_MODE', () => {
process.env.MCP_MODE = '' as any;
const originalLog = console.log;
manager.silence();
expect(console.log).toBe(originalLog);
expect(manager.isActive).toBe(false);
});
test('should silence and restore multiple times', () => {
process.env.MCP_MODE = 'http';
const originalLog = console.log;
// First cycle
manager.silence();
expect(manager.isActive).toBe(true);
manager.restore();
expect(manager.isActive).toBe(false);
expect(console.log).toBe(originalLog);
// Second cycle
manager.silence();
expect(manager.isActive).toBe(true);
manager.restore();
expect(manager.isActive).toBe(false);
expect(console.log).toBe(originalLog);
});
});
});

View File

@@ -0,0 +1,786 @@
import { describe, test, expect } from 'vitest';
import { FixedCollectionValidator, NodeConfig, NodeConfigValue } from '../../../src/utils/fixed-collection-validator';
// Type guard helper for tests
function isNodeConfig(value: NodeConfig | NodeConfigValue[] | undefined): value is NodeConfig {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
describe('FixedCollectionValidator', () => {
describe('Core Functionality', () => {
test('should return valid for non-susceptible nodes', () => {
const result = FixedCollectionValidator.validate('n8n-nodes-base.cron', {
triggerTimes: { hour: 10, minute: 30 }
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('should normalize node types correctly', () => {
const nodeTypes = [
'n8n-nodes-base.switch',
'nodes-base.switch',
'@n8n/n8n-nodes-langchain.switch',
'SWITCH'
];
nodeTypes.forEach(nodeType => {
expect(FixedCollectionValidator.isNodeSusceptible(nodeType)).toBe(true);
});
});
test('should get all known patterns', () => {
const patterns = FixedCollectionValidator.getAllPatterns();
expect(patterns.length).toBeGreaterThan(10); // We have at least 11 patterns
expect(patterns.some(p => p.nodeType === 'switch')).toBe(true);
expect(patterns.some(p => p.nodeType === 'summarize')).toBe(true);
});
});
describe('Switch Node Validation', () => {
test('should detect invalid nested conditions structure', () => {
const invalidConfig = {
rules: {
conditions: {
values: [
{
value1: '={{$json.status}}',
operation: 'equals',
value2: 'active'
}
]
}
}
};
const result = FixedCollectionValidator.validate('n8n-nodes-base.switch', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors).toHaveLength(2); // Both rules.conditions and rules.conditions.values match
// Check that we found the specific pattern
const conditionsValuesError = result.errors.find(e => e.pattern === 'rules.conditions.values');
expect(conditionsValuesError).toBeDefined();
expect(conditionsValuesError!.message).toContain('propertyValues[itemName] is not iterable');
expect(result.autofix).toBeDefined();
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect(result.autofix.rules).toBeDefined();
expect((result.autofix.rules as any).values).toBeDefined();
expect((result.autofix.rules as any).values[0].outputKey).toBe('output1');
}
});
test('should provide correct autofix for switch node', () => {
const invalidConfig = {
rules: {
conditions: {
values: [
{ value1: '={{$json.a}}', operation: 'equals', value2: '1' },
{ value1: '={{$json.b}}', operation: 'equals', value2: '2' }
]
}
}
};
const result = FixedCollectionValidator.validate('switch', invalidConfig);
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.rules as any).values).toHaveLength(2);
expect((result.autofix.rules as any).values[0].outputKey).toBe('output1');
expect((result.autofix.rules as any).values[1].outputKey).toBe('output2');
}
});
});
describe('If/Filter Node Validation', () => {
test('should detect invalid nested values structure', () => {
const invalidConfig = {
conditions: {
values: [
{
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
]
}
};
const ifResult = FixedCollectionValidator.validate('n8n-nodes-base.if', invalidConfig);
const filterResult = FixedCollectionValidator.validate('n8n-nodes-base.filter', invalidConfig);
expect(ifResult.isValid).toBe(false);
expect(ifResult.errors[0].fix).toContain('directly, not nested under "values"');
expect(ifResult.autofix).toEqual([
{
value1: '={{$json.age}}',
operation: 'largerEqual',
value2: 18
}
]);
expect(filterResult.isValid).toBe(false);
expect(filterResult.autofix).toEqual(ifResult.autofix);
});
});
describe('New Nodes Validation', () => {
test('should validate Summarize node', () => {
const invalidConfig = {
fieldsToSummarize: {
values: {
values: [
{ field: 'amount', aggregation: 'sum' },
{ field: 'count', aggregation: 'count' }
]
}
}
};
const result = FixedCollectionValidator.validate('summarize', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors[0].pattern).toBe('fieldsToSummarize.values.values');
expect(result.errors[0].fix).toContain('not nested values.values');
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.fieldsToSummarize as any).values).toHaveLength(2);
}
});
test('should validate Compare Datasets node', () => {
const invalidConfig = {
mergeByFields: {
values: {
values: [
{ field1: 'id', field2: 'userId' }
]
}
}
};
const result = FixedCollectionValidator.validate('compareDatasets', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors[0].pattern).toBe('mergeByFields.values.values');
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.mergeByFields as any).values).toHaveLength(1);
}
});
test('should validate Sort node', () => {
const invalidConfig = {
sortFieldsUi: {
sortField: {
values: [
{ fieldName: 'date', order: 'descending' }
]
}
}
};
const result = FixedCollectionValidator.validate('sort', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors[0].pattern).toBe('sortFieldsUi.sortField.values');
expect(result.errors[0].fix).toContain('not sortField.values');
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.sortFieldsUi as any).sortField).toHaveLength(1);
}
});
test('should validate Aggregate node', () => {
const invalidConfig = {
fieldsToAggregate: {
fieldToAggregate: {
values: [
{ fieldToAggregate: 'price', aggregation: 'average' }
]
}
}
};
const result = FixedCollectionValidator.validate('aggregate', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors[0].pattern).toBe('fieldsToAggregate.fieldToAggregate.values');
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.fieldsToAggregate as any).fieldToAggregate).toHaveLength(1);
}
});
test('should validate Set node', () => {
const invalidConfig = {
fields: {
values: {
values: [
{ name: 'status', value: 'active' }
]
}
}
};
const result = FixedCollectionValidator.validate('set', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors[0].pattern).toBe('fields.values.values');
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.fields as any).values).toHaveLength(1);
}
});
test('should validate HTML node', () => {
const invalidConfig = {
extractionValues: {
values: {
values: [
{ key: 'title', cssSelector: 'h1' }
]
}
}
};
const result = FixedCollectionValidator.validate('html', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors[0].pattern).toBe('extractionValues.values.values');
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.extractionValues as any).values).toHaveLength(1);
}
});
test('should validate HTTP Request node', () => {
const invalidConfig = {
body: {
parameters: {
values: [
{ name: 'api_key', value: '123' }
]
}
}
};
const result = FixedCollectionValidator.validate('httpRequest', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors[0].pattern).toBe('body.parameters.values');
expect(result.errors[0].fix).toContain('not parameters.values');
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.body as any).parameters).toHaveLength(1);
}
});
test('should validate Airtable node', () => {
const invalidConfig = {
sort: {
sortField: {
values: [
{ fieldName: 'Created', direction: 'desc' }
]
}
}
};
const result = FixedCollectionValidator.validate('airtable', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.errors[0].pattern).toBe('sort.sortField.values');
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
expect((result.autofix.sort as any).sortField).toHaveLength(1);
}
});
});
describe('Edge Cases', () => {
test('should handle empty config', () => {
const result = FixedCollectionValidator.validate('switch', {});
expect(result.isValid).toBe(true);
});
test('should handle null/undefined properties', () => {
const result = FixedCollectionValidator.validate('switch', {
rules: null
});
expect(result.isValid).toBe(true);
});
test('should handle valid structures', () => {
const validSwitch = {
rules: {
values: [
{
conditions: { value1: '={{$json.x}}', operation: 'equals', value2: 1 },
outputKey: 'output1'
}
]
}
};
const result = FixedCollectionValidator.validate('switch', validSwitch);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('should handle deeply nested invalid structures', () => {
const deeplyNested = {
rules: {
conditions: {
values: [
{
value1: '={{$json.deep}}',
operation: 'equals',
value2: 'nested'
}
]
}
}
};
const result = FixedCollectionValidator.validate('switch', deeplyNested);
expect(result.isValid).toBe(false);
expect(result.errors).toHaveLength(2); // Both patterns match
});
});
describe('Private Method Testing (through public API)', () => {
describe('isNodeConfig Type Guard', () => {
test('should return true for plain objects', () => {
const validConfig = { property: 'value' };
const result = FixedCollectionValidator.validate('switch', validConfig);
// Type guard is tested indirectly through validation
expect(result).toBeDefined();
});
test('should handle null values correctly', () => {
const result = FixedCollectionValidator.validate('switch', null as any);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('should handle undefined values correctly', () => {
const result = FixedCollectionValidator.validate('switch', undefined as any);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('should handle arrays correctly', () => {
const result = FixedCollectionValidator.validate('switch', [] as any);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('should handle primitive values correctly', () => {
const result1 = FixedCollectionValidator.validate('switch', 'string' as any);
expect(result1.isValid).toBe(true);
const result2 = FixedCollectionValidator.validate('switch', 123 as any);
expect(result2.isValid).toBe(true);
const result3 = FixedCollectionValidator.validate('switch', true as any);
expect(result3.isValid).toBe(true);
});
});
describe('getNestedValue Testing', () => {
test('should handle simple nested paths', () => {
const config = {
rules: {
conditions: {
values: [{ test: 'value' }]
}
}
};
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(false); // This tests the nested value extraction
});
test('should handle non-existent paths gracefully', () => {
const config = {
rules: {
// missing conditions property
}
};
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(true); // Should not find invalid structure
});
test('should handle interrupted paths (null/undefined in middle)', () => {
const config = {
rules: null
};
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(true);
});
test('should handle array interruptions in path', () => {
const config = {
rules: [1, 2, 3] // array instead of object
};
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(true); // Should not find the pattern
});
});
describe('Circular Reference Protection', () => {
test('should handle circular references in config', () => {
const config: any = {
rules: {
conditions: {}
}
};
// Create circular reference
config.rules.conditions.circular = config.rules;
const result = FixedCollectionValidator.validate('switch', config);
// Should not crash and should detect the pattern (result is false because it finds rules.conditions)
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
test('should handle self-referencing objects', () => {
const config: any = {
rules: {}
};
config.rules.self = config.rules;
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(true);
});
test('should handle deeply nested circular references', () => {
const config: any = {
rules: {
conditions: {
values: {}
}
}
};
config.rules.conditions.values.back = config;
const result = FixedCollectionValidator.validate('switch', config);
// Should detect the problematic pattern: rules.conditions.values exists
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
describe('Deep Copying in getAllPatterns', () => {
test('should return independent copies of patterns', () => {
const patterns1 = FixedCollectionValidator.getAllPatterns();
const patterns2 = FixedCollectionValidator.getAllPatterns();
// Modify one copy
patterns1[0].invalidPatterns.push('test.pattern');
// Other copy should be unaffected
expect(patterns2[0].invalidPatterns).not.toContain('test.pattern');
});
test('should deep copy invalidPatterns arrays', () => {
const patterns = FixedCollectionValidator.getAllPatterns();
const switchPattern = patterns.find(p => p.nodeType === 'switch')!;
expect(switchPattern.invalidPatterns).toBeInstanceOf(Array);
expect(switchPattern.invalidPatterns.length).toBeGreaterThan(0);
// Ensure it's a different array instance
const originalPatterns = FixedCollectionValidator.getAllPatterns();
const originalSwitch = originalPatterns.find(p => p.nodeType === 'switch')!;
expect(switchPattern.invalidPatterns).not.toBe(originalSwitch.invalidPatterns);
expect(switchPattern.invalidPatterns).toEqual(originalSwitch.invalidPatterns);
});
});
});
describe('Enhanced Edge Cases', () => {
test('should handle hasOwnProperty edge case', () => {
const config = Object.create(null);
config.rules = {
conditions: {
values: [{ test: 'value' }]
}
};
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(false); // Should still detect the pattern
});
test('should handle prototype pollution attempts', () => {
const config = {
rules: {
conditions: {
values: [{ test: 'value' }]
}
}
};
// Add prototype property (should be ignored by hasOwnProperty check)
(Object.prototype as any).maliciousProperty = 'evil';
try {
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(false);
expect(result.errors).toHaveLength(2);
} finally {
delete (Object.prototype as any).maliciousProperty;
}
});
test('should handle objects with numeric keys', () => {
const config = {
rules: {
'0': {
values: [{ test: 'value' }]
}
}
};
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(true); // Should not match 'conditions' pattern
});
test('should handle very deep nesting without crashing', () => {
let deepConfig: any = {};
let current = deepConfig;
// Create 100 levels deep
for (let i = 0; i < 100; i++) {
current.next = {};
current = current.next;
}
const result = FixedCollectionValidator.validate('switch', deepConfig);
expect(result.isValid).toBe(true);
});
});
describe('Alternative Node Type Formats', () => {
test('should handle all node type normalization cases', () => {
const testCases = [
'n8n-nodes-base.switch',
'nodes-base.switch',
'@n8n/n8n-nodes-langchain.switch',
'SWITCH',
'Switch',
'sWiTcH'
];
testCases.forEach(nodeType => {
expect(FixedCollectionValidator.isNodeSusceptible(nodeType)).toBe(true);
});
});
test('should handle empty and invalid node types', () => {
expect(FixedCollectionValidator.isNodeSusceptible('')).toBe(false);
expect(FixedCollectionValidator.isNodeSusceptible('unknown-node')).toBe(false);
expect(FixedCollectionValidator.isNodeSusceptible('n8n-nodes-base.unknown')).toBe(false);
});
});
describe('Complex Autofix Scenarios', () => {
test('should handle switch autofix with non-array values', () => {
const invalidConfig = {
rules: {
conditions: {
values: { single: 'condition' } // Object instead of array
}
}
};
const result = FixedCollectionValidator.validate('switch', invalidConfig);
expect(result.isValid).toBe(false);
expect(isNodeConfig(result.autofix)).toBe(true);
if (isNodeConfig(result.autofix)) {
const values = (result.autofix.rules as any).values;
expect(values).toHaveLength(1);
expect(values[0].conditions).toEqual({ single: 'condition' });
expect(values[0].outputKey).toBe('output1');
}
});
test('should handle if/filter autofix with object values', () => {
const invalidConfig = {
conditions: {
values: { type: 'single', condition: 'test' }
}
};
const result = FixedCollectionValidator.validate('if', invalidConfig);
expect(result.isValid).toBe(false);
expect(result.autofix).toEqual({ type: 'single', condition: 'test' });
});
test('should handle applyAutofix for if/filter with null values', () => {
const invalidConfig = {
conditions: {
values: null
}
};
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if')!;
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern);
// Should return the original config when values is null
expect(fixed).toEqual(invalidConfig);
});
test('should handle applyAutofix for if/filter with undefined values', () => {
const invalidConfig = {
conditions: {
values: undefined
}
};
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if')!;
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern);
// Should return the original config when values is undefined
expect(fixed).toEqual(invalidConfig);
});
});
describe('applyAutofix Method', () => {
test('should apply autofix correctly for if/filter nodes', () => {
const invalidConfig = {
conditions: {
values: [
{ value1: '={{$json.test}}', operation: 'equals', value2: 'yes' }
]
}
};
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if');
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!);
expect(fixed).toEqual([
{ value1: '={{$json.test}}', operation: 'equals', value2: 'yes' }
]);
});
test('should return original config for non-if/filter nodes', () => {
const invalidConfig = {
fieldsToSummarize: {
values: {
values: [{ field: 'test' }]
}
}
};
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'summarize');
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!);
expect(isNodeConfig(fixed)).toBe(true);
if (isNodeConfig(fixed)) {
expect((fixed.fieldsToSummarize as any).values).toEqual([{ field: 'test' }]);
}
});
test('should handle filter node applyAutofix edge cases', () => {
const invalidConfig = {
conditions: {
values: 'string-value' // Invalid type
}
};
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'filter');
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!);
// Should return original config when values is not object/array
expect(fixed).toEqual(invalidConfig);
});
});
describe('Missing Function Coverage Tests', () => {
test('should test all generateFixMessage cases', () => {
// Test each node type's fix message generation through validation
const nodeConfigs = [
{ nodeType: 'switch', config: { rules: { conditions: { values: [] } } } },
{ nodeType: 'if', config: { conditions: { values: [] } } },
{ nodeType: 'filter', config: { conditions: { values: [] } } },
{ nodeType: 'summarize', config: { fieldsToSummarize: { values: { values: [] } } } },
{ nodeType: 'comparedatasets', config: { mergeByFields: { values: { values: [] } } } },
{ nodeType: 'sort', config: { sortFieldsUi: { sortField: { values: [] } } } },
{ nodeType: 'aggregate', config: { fieldsToAggregate: { fieldToAggregate: { values: [] } } } },
{ nodeType: 'set', config: { fields: { values: { values: [] } } } },
{ nodeType: 'html', config: { extractionValues: { values: { values: [] } } } },
{ nodeType: 'httprequest', config: { body: { parameters: { values: [] } } } },
{ nodeType: 'airtable', config: { sort: { sortField: { values: [] } } } },
];
nodeConfigs.forEach(({ nodeType, config }) => {
const result = FixedCollectionValidator.validate(nodeType, config);
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0].fix).toBeDefined();
expect(typeof result.errors[0].fix).toBe('string');
});
});
test('should test default case in generateFixMessage', () => {
// Create a custom pattern with unknown nodeType to test default case
const mockPattern = {
nodeType: 'unknown-node-type',
property: 'testProperty',
expectedStructure: 'test.structure',
invalidPatterns: ['test.invalid.pattern']
};
// We can't directly test the private generateFixMessage method,
// but we can test through the validation logic by temporarily adding to KNOWN_PATTERNS
// Instead, let's verify the method works by checking error messages contain the expected structure
const patterns = FixedCollectionValidator.getAllPatterns();
expect(patterns.length).toBeGreaterThan(0);
// Ensure we have patterns that would exercise different fix message paths
const switchPattern = patterns.find(p => p.nodeType === 'switch');
expect(switchPattern).toBeDefined();
expect(switchPattern!.expectedStructure).toBe('rules.values array');
});
test('should exercise hasInvalidStructure edge cases', () => {
// Test with property that exists but is not at the end of the pattern
const config = {
rules: {
conditions: 'string-value' // Not an object, so traversal should stop
}
};
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(false); // Should still detect rules.conditions pattern
});
test('should test getNestedValue with complex paths', () => {
// Test through hasInvalidStructure which uses getNestedValue
const config = {
deeply: {
nested: {
path: {
to: {
value: 'exists'
}
}
}
}
};
// This would exercise the getNestedValue function through hasInvalidStructure
const result = FixedCollectionValidator.validate('switch', config);
expect(result.isValid).toBe(true); // No matching patterns
});
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SimpleCache } from '../../../src/utils/simple-cache';
describe('SimpleCache Memory Leak Fix', () => {
let cache: SimpleCache;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
if (cache && typeof cache.destroy === 'function') {
cache.destroy();
}
vi.restoreAllMocks();
});
it('should track cleanup timer', () => {
cache = new SimpleCache();
// Access private property for testing
expect((cache as any).cleanupTimer).toBeDefined();
expect((cache as any).cleanupTimer).not.toBeNull();
});
it('should clear timer on destroy', () => {
cache = new SimpleCache();
const timer = (cache as any).cleanupTimer;
cache.destroy();
expect((cache as any).cleanupTimer).toBeNull();
// Verify timer was cleared
expect(() => clearInterval(timer)).not.toThrow();
});
it('should clear cache on destroy', () => {
cache = new SimpleCache();
cache.set('test-key', 'test-value', 300);
expect(cache.get('test-key')).toBe('test-value');
cache.destroy();
expect(cache.get('test-key')).toBeNull();
});
it('should handle multiple destroy calls safely', () => {
cache = new SimpleCache();
expect(() => {
cache.destroy();
cache.destroy();
cache.destroy();
}).not.toThrow();
expect((cache as any).cleanupTimer).toBeNull();
});
it('should not create new timers after destroy', () => {
cache = new SimpleCache();
const originalTimer = (cache as any).cleanupTimer;
cache.destroy();
// Try to use the cache after destroy
cache.set('key', 'value');
cache.get('key');
cache.clear();
// Timer should still be null
expect((cache as any).cleanupTimer).toBeNull();
expect((cache as any).cleanupTimer).not.toBe(originalTimer);
});
it('should clean up expired entries periodically', () => {
cache = new SimpleCache();
// Set items with different TTLs
cache.set('short', 'value1', 1); // 1 second
cache.set('long', 'value2', 300); // 300 seconds
// Advance time by 2 seconds
vi.advanceTimersByTime(2000);
// Advance time to trigger cleanup (60 seconds)
vi.advanceTimersByTime(58000);
// Short-lived item should be gone
expect(cache.get('short')).toBeNull();
// Long-lived item should still exist
expect(cache.get('long')).toBe('value2');
});
it('should prevent memory leak by clearing timer', () => {
const timers: NodeJS.Timeout[] = [];
const originalSetInterval = global.setInterval;
// Mock setInterval to track created timers
global.setInterval = vi.fn((callback, delay) => {
const timer = originalSetInterval(callback, delay);
timers.push(timer);
return timer;
});
// Create and destroy multiple caches
for (let i = 0; i < 5; i++) {
const tempCache = new SimpleCache();
tempCache.set(`key${i}`, `value${i}`);
tempCache.destroy();
}
// All timers should have been cleared
expect(timers.length).toBe(5);
// Restore original setInterval
global.setInterval = originalSetInterval;
});
it('should have destroy method defined', () => {
cache = new SimpleCache();
expect(typeof cache.destroy).toBe('function');
});
});