mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 22:42:04 +00:00
Compare commits
1 Commits
v2.22.7
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7da0a2430 |
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@@ -142,13 +142,19 @@ jobs:
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "ℹ️ No previous tag found, this might be the first release"
|
||||
|
||||
# Generate initial release notes using script
|
||||
if NOTES=$(node scripts/generate-initial-release-notes.js "$CURRENT_VERSION" 2>/dev/null); then
|
||||
echo "✅ Successfully generated initial release notes for version $CURRENT_VERSION"
|
||||
else
|
||||
echo "⚠️ Could not generate initial release notes for version $CURRENT_VERSION"
|
||||
NOTES="Initial release v$CURRENT_VERSION"
|
||||
fi
|
||||
# Get all commits up to current commit - use heredoc for multiline
|
||||
NOTES=$(cat <<EOF
|
||||
### 🎉 Initial Release
|
||||
|
||||
This is the initial release of n8n-mcp v$CURRENT_VERSION.
|
||||
|
||||
---
|
||||
|
||||
**Release Statistics:**
|
||||
- Commit count: $(git rev-list --count HEAD)
|
||||
- First release setup
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "has-notes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
375
CHANGELOG.md
375
CHANGELOG.md
@@ -7,381 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.22.7] - 2025-10-26
|
||||
|
||||
### 📝 Documentation Fixes
|
||||
|
||||
**Issue #292: Corrected Array Property Removal Documentation in n8n_update_partial_workflow**
|
||||
|
||||
Fixed critical documentation error in property removal patterns that could have led users to write non-functional code.
|
||||
|
||||
#### Problem
|
||||
|
||||
The documentation incorrectly showed using array index notation `[0]` for removing array elements:
|
||||
```javascript
|
||||
// INCORRECT (doesn't work as shown)
|
||||
updates: { "parameters.headers[0]": undefined }
|
||||
```
|
||||
|
||||
**Root Cause**: The `setNestedProperty` implementation doesn't parse array index notation like `[0]`. It treats `headers[0]` as a literal object key, not an array index.
|
||||
|
||||
**Impact**: Users following the documentation would write code that doesn't behave as expected. The property `headers[0]` would be treated as an object key, not an array element reference.
|
||||
|
||||
#### Fixed
|
||||
|
||||
**Three documentation corrections in `src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts`:**
|
||||
|
||||
1. **Nested Property Removal Section** (lines 236-244):
|
||||
- Changed comment from `// Remove array element` to `// Remove entire array property`
|
||||
- Changed code from `"parameters.headers[0]": undefined` to `"parameters.headers": undefined`
|
||||
|
||||
2. **Example rm5** (line 340):
|
||||
- Changed comment from `// Remove array element` to `// Remove entire array property`
|
||||
- Changed code from `"parameters.headers[0]": undefined` to `"parameters.headers": undefined`
|
||||
|
||||
3. **Pitfalls Section** (line 405):
|
||||
- OLD: `'Array element removal with undefined removes the element at that index, which may shift subsequent indices'`
|
||||
- NEW: `'Array index notation (e.g., "parameters.headers[0]") is not supported - remove the entire array property instead'`
|
||||
|
||||
#### Correct Usage
|
||||
|
||||
**To remove an array property:**
|
||||
```javascript
|
||||
// Correct: Remove entire array
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_012",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: { "parameters.headers": undefined } // Remove entire headers array
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
**NOT:**
|
||||
```javascript
|
||||
// Incorrect: Array index notation doesn't work
|
||||
updates: { "parameters.headers[0]": undefined } // Treated as object key "headers[0]"
|
||||
```
|
||||
|
||||
#### Impact
|
||||
|
||||
- **Prevents User Confusion**: Clear documentation on what works vs. what doesn't
|
||||
- **Accurate Examples**: All examples now show correct, working patterns
|
||||
- **Better Error Prevention**: Pitfall warning helps users avoid this mistake
|
||||
- **No Code Changes**: This is purely a documentation fix - no implementation changes needed
|
||||
|
||||
#### Testing
|
||||
|
||||
- ✅ Documentation reviewed by code-reviewer agent
|
||||
- ✅ Tested by n8n-mcp-tester agent
|
||||
- ✅ All examples verified against actual implementation behavior
|
||||
- ✅ Pitfall accurately describes technical limitation
|
||||
|
||||
#### Files Changed
|
||||
|
||||
**Documentation (1 file)**:
|
||||
- `src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts` - Corrected 3 instances of array property removal documentation
|
||||
|
||||
**Configuration (2 files)**:
|
||||
- `package.json` - Version bump to 2.22.7
|
||||
- `package.runtime.json` - Version bump to 2.22.7
|
||||
|
||||
#### Related
|
||||
|
||||
- **Issue**: #292 - Missing documentation on how to remove node properties using `updateNode`
|
||||
- **PR**: #375 - Resolve GitHub Issue 292 in n8n-mcp
|
||||
- **Code Review**: Identified critical array index notation documentation error
|
||||
- **Root Cause**: Implementation doesn't parse array bracket notation `[N]`
|
||||
|
||||
Conceived by Romuald Członkowski - [www.aiadvisors.pl/en](https://www.aiadvisors.pl/en)
|
||||
|
||||
## [2.22.6] - 2025-10-25
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
**Issue #228: Fix Docker Port Configuration Mismatch**
|
||||
|
||||
Fixed critical Docker configuration bug where custom PORT environment variable values were not properly mapped to container ports, causing connection failures in Docker deployments.
|
||||
|
||||
#### Problem
|
||||
- **docker-compose.yml**: Port mapping `"${PORT:-3000}:3000"` hardcoded container port to 3000
|
||||
- **docker-compose.yml**: Health check hardcoded to port 3000
|
||||
- **Dockerfile**: Health check hardcoded to port 3000
|
||||
- Impact: When PORT≠3000 (e.g., PORT=8080), Docker mapped host port to wrong container port
|
||||
|
||||
#### Solution
|
||||
- **docker-compose.yml line 44**: Changed port mapping to `"${PORT:-3000}:${PORT:-3000}"`
|
||||
- **docker-compose.yml line 56**: Updated health check to use dynamic port `$${PORT:-3000}`
|
||||
- **Dockerfile line 93**: Updated HEALTHCHECK to use dynamic port `${PORT:-3000}`
|
||||
- **Dockerfile line 85**: Added clarifying comment about PORT configurability
|
||||
|
||||
#### Testing
|
||||
- Verified with default PORT (3000)
|
||||
- Verified with custom PORT (8080)
|
||||
- Health checks work correctly in both scenarios
|
||||
|
||||
#### Related Issues
|
||||
- Fixes #228 (Docker Compose port error)
|
||||
- Likely fixes #109 (Configuration ignored in HTTP mode)
|
||||
- Likely fixes #84 (Can't access container)
|
||||
|
||||
Conceived by Romuald Członkowski - [www.aiadvisors.pl/en](https://www.aiadvisors.pl/en)
|
||||
|
||||
## [2.22.3] - 2025-10-25
|
||||
|
||||
### 🔧 Code Quality Improvements
|
||||
|
||||
**Issue #349: Refactor n8n API Response Validation (PR #367)**
|
||||
|
||||
Improved code maintainability and added comprehensive test coverage for defensive response validation added in PR #367.
|
||||
|
||||
#### Refactoring
|
||||
|
||||
**1. Eliminated DRY Violation**
|
||||
- Extracted duplicated validation logic into `validateListResponse<T>()` helper method
|
||||
- Reduced code duplication from 88 lines to single reusable function
|
||||
- Impact: 75% reduction in validation code, easier maintenance
|
||||
|
||||
**2. Enhanced Error Handling**
|
||||
- Consistent error message format across all list operations
|
||||
- Limited error message verbosity (max 5 keys shown to prevent information exposure)
|
||||
- Added security protection against data structure exposure
|
||||
- Better error messages: `got object with keys: [data, items, total, hasMore, meta]`
|
||||
|
||||
**3. Improved Documentation**
|
||||
- Added JSDoc comments explaining backwards compatibility
|
||||
- Documented modern vs legacy response formats
|
||||
- Referenced issue #349 for context
|
||||
|
||||
#### Testing
|
||||
|
||||
**Added Comprehensive Unit Tests** (29 new test cases)
|
||||
- Legacy array format wrapping for all 4 methods
|
||||
- Null/undefined response handling
|
||||
- Primitive type rejection (string, number, boolean)
|
||||
- Invalid structure detection
|
||||
- Non-array data field validation
|
||||
- Error message truncation with many keys
|
||||
- 100% coverage of new validation logic
|
||||
|
||||
**Test Coverage Results**:
|
||||
- Before: 0% coverage of validation scenarios
|
||||
- After: 100% coverage (29/29 scenarios tested)
|
||||
- All validation paths exercised and verified
|
||||
|
||||
#### Impact
|
||||
|
||||
**Code Quality**:
|
||||
- ✅ DRY principle restored (no duplication)
|
||||
- ✅ Type safety improved with generics
|
||||
- ✅ Consistent error handling across all methods
|
||||
- ✅ Well-documented backwards compatibility
|
||||
|
||||
**Maintainability**:
|
||||
- ✅ Single source of truth for validation logic
|
||||
- ✅ Future bug fixes apply to all methods automatically
|
||||
- ✅ Easier to understand and modify
|
||||
|
||||
**Security**:
|
||||
- ✅ Limited information exposure in error messages
|
||||
- ✅ Protection against verbose error logs
|
||||
|
||||
**Testing**:
|
||||
- ✅ Full test coverage prevents regressions
|
||||
- ✅ All edge cases validated
|
||||
- ✅ Backwards compatibility verified
|
||||
|
||||
#### Files Modified
|
||||
|
||||
**Code (1 file)**:
|
||||
- `src/services/n8n-api-client.ts`
|
||||
- Added `validateListResponse<T>()` private helper method (44 lines)
|
||||
- Refactored listWorkflows, listExecutions, listCredentials, listTags (reduced from ~100 lines to ~20 lines)
|
||||
- Added JSDoc documentation to all 4 list methods
|
||||
- Net reduction: ~80 lines of code
|
||||
|
||||
**Tests (1 file)**:
|
||||
- `tests/unit/services/n8n-api-client.test.ts`
|
||||
- Added 29 comprehensive validation test cases (237 lines)
|
||||
- Coverage for all 4 list methods
|
||||
- Tests for legacy format, null responses, invalid structures, key truncation
|
||||
|
||||
**Configuration (1 file)**:
|
||||
- `package.json` - Version bump to 2.22.3
|
||||
|
||||
#### Technical Details
|
||||
|
||||
**Helper Method Signature**:
|
||||
```typescript
|
||||
private validateListResponse<T>(
|
||||
responseData: any,
|
||||
resourceType: string
|
||||
): { data: T[]; nextCursor?: string | null }
|
||||
```
|
||||
|
||||
**Error Message Example**:
|
||||
```
|
||||
Invalid response from n8n API for workflows: expected {data: [], nextCursor?: string},
|
||||
got object with keys: [items, total, hasMore, page, limit]...
|
||||
```
|
||||
|
||||
**Usage Example**:
|
||||
```typescript
|
||||
async listWorkflows(params: WorkflowListParams = {}): Promise<WorkflowListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/workflows', { params });
|
||||
return this.validateListResponse<Workflow>(response.data, 'workflows');
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Related
|
||||
|
||||
- **Issue**: #349 - Response validation for n8n API list operations
|
||||
- **PR**: #367 - Add defensive response validation (original implementation)
|
||||
- **Code Review**: Identified DRY violation and missing test coverage
|
||||
- **Testing**: Validated by n8n-mcp-tester agent
|
||||
- **Analysis**: Both agents confirmed functional correctness, recommended refactoring
|
||||
|
||||
Conceived by Romuald Członkowski - [www.aiadvisors.pl/en](https://www.aiadvisors.pl/en)
|
||||
|
||||
---
|
||||
|
||||
### ✨ Enhancements
|
||||
|
||||
**Issue #361: Enhanced HTTP Request Node Validation Suggestions**
|
||||
|
||||
Added helpful suggestions for HTTP Request node best practices to prevent common production issues discovered through real-world workflow analysis.
|
||||
|
||||
#### What's New
|
||||
|
||||
1. **alwaysOutputData Suggestion**
|
||||
- Suggests adding `alwaysOutputData: true` at node level (not in parameters)
|
||||
- Prevents silent workflow failures when HTTP requests error
|
||||
- Ensures downstream error handling can process failed requests
|
||||
- Example suggestion: "Consider adding alwaysOutputData: true at node level for better error handling. This ensures the node produces output even when HTTP requests fail, allowing downstream error handling."
|
||||
|
||||
2. **responseFormat Suggestion for API Endpoints**
|
||||
- Suggests setting `options.response.response.responseFormat` for API endpoints
|
||||
- Prevents JSON parsing confusion
|
||||
- Triggered when URL contains `/api`, `/rest`, `supabase`, `firebase`, `googleapis`, or `.com/v` patterns
|
||||
- Example suggestion: "API endpoints should explicitly set options.response.response.responseFormat to 'json' or 'text' to prevent confusion about response parsing"
|
||||
|
||||
3. **Enhanced URL Protocol Validation**
|
||||
- Detects missing protocol in expression-based URLs
|
||||
- Warns about patterns like `=www.{{ $json.domain }}.com` (missing http://)
|
||||
- Warns about expressions without protocol: `={{ $json.domain }}/api/data`
|
||||
- Example warning: "URL expression appears to be missing http:// or https:// protocol"
|
||||
|
||||
#### Investigation Findings
|
||||
|
||||
This enhancement was developed after thorough investigation of issue #361:
|
||||
|
||||
**Key Discoveries:**
|
||||
- ✅ Mixed expression syntax `=literal{{ expression }}` **actually works in n8n** - the issue report's primary claim was incorrect
|
||||
- ✅ Real validation gaps identified: missing `alwaysOutputData` and `responseFormat` checks
|
||||
- ✅ Workflow analysis showed "?" icon in UI caused by missing required URL (already caught by validation)
|
||||
- ✅ Compared broken vs fixed workflows to identify actual production issues
|
||||
|
||||
**Testing Evidence:**
|
||||
- Analyzed workflow SwjKJsJhe8OsYfBk with mixed syntax - executions successful
|
||||
- Compared broken workflow (mBmkyj460i5rYTG4) with fixed workflow (hQI9pby3nSFtk4TV)
|
||||
- Identified that fixed workflow has `alwaysOutputData: true` and explicit `responseFormat: "json"`
|
||||
|
||||
#### Impact
|
||||
|
||||
- **Non-Breaking**: All changes are suggestions/warnings, not errors
|
||||
- **Profile-Aware**: Suggestions shown in all profiles for maximum helpfulness
|
||||
- **Actionable**: Clear guidance on how to implement best practices
|
||||
- **Production-Focused**: Addresses real workflow reliability concerns from actual broken workflows
|
||||
|
||||
#### Test Coverage
|
||||
|
||||
Added 8 new test cases covering:
|
||||
- alwaysOutputData suggestion for all HTTP Request nodes
|
||||
- responseFormat suggestion for API endpoint detection (various patterns)
|
||||
- responseFormat NOT suggested when already configured
|
||||
- URL protocol validation for expression-based URLs
|
||||
- Protocol warnings for missing http:// in expressions
|
||||
- No false positives when protocol is correctly included
|
||||
|
||||
#### Technical Details
|
||||
|
||||
**Files Modified:**
|
||||
- `src/services/enhanced-config-validator.ts` - Added `enhanceHttpRequestValidation()` implementation
|
||||
- `tests/unit/services/enhanced-config-validator.test.ts` - Added 8 comprehensive test cases
|
||||
|
||||
**Validation Flow:**
|
||||
1. Check for alwaysOutputData suggestion (all HTTP Request nodes)
|
||||
2. Detect API endpoints by URL patterns
|
||||
3. Check for explicit responseFormat configuration
|
||||
4. Validate expression-based URLs for protocol issues
|
||||
|
||||
#### Related
|
||||
|
||||
- **Issue**: #361 - validate_node_operation: Missing critical HTTP Request node configuration checks
|
||||
- **Analysis**: Deep investigation with @agent-Explore and @agent-n8n-mcp-tester
|
||||
- **Workflows Analyzed**:
|
||||
- SwjKJsJhe8OsYfBk (mixed syntax test)
|
||||
- mBmkyj460i5rYTG4 (broken workflow)
|
||||
- hQI9pby3nSFtk4TV (fixed workflow)
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
---
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
**Issue #360: Enhanced Warnings for If/Switch Node Connection Parameters**
|
||||
|
||||
Fixed issue where users could unintentionally place multiple If node connections on the same branch (TRUE/FALSE) when using `sourceIndex` parameter instead of the recommended `branch` parameter. The system now provides helpful warnings to guide users toward better practices.
|
||||
|
||||
#### What Was Fixed
|
||||
|
||||
1. **New Warning System**:
|
||||
- Warns when using `sourceIndex` with If nodes - suggests `branch="true"` or `branch="false"` instead
|
||||
- Warns when using `sourceIndex` with Switch nodes - suggests `case=N` instead
|
||||
- Explains the correct branch structure: `main[0]=TRUE branch, main[1]=FALSE branch`
|
||||
|
||||
2. **Enhanced Documentation**:
|
||||
- Added **CRITICAL** pitfalls to `n8n_update_partial_workflow` tool documentation
|
||||
- Clear guidance that using `sourceIndex=0` for multiple connections puts them ALL on the TRUE branch
|
||||
- Examples showing correct vs. incorrect usage
|
||||
|
||||
3. **Type System Improvements**:
|
||||
- Added `warnings` field to `WorkflowDiffResult` interface
|
||||
- Warnings are non-blocking (operations still succeed)
|
||||
- Differentiated from errors for better UX
|
||||
|
||||
#### Behavior
|
||||
|
||||
The existing `branch` parameter works correctly and has comprehensive test coverage:
|
||||
- `branch="true"` → routes to `main[0]` (TRUE path)
|
||||
- `branch="false"` → routes to `main[1]` (FALSE path)
|
||||
|
||||
The issue was that users who didn't know about the `branch` parameter would naturally use `sourceIndex`, which led to incorrect branch routing.
|
||||
|
||||
#### Example Warning
|
||||
|
||||
```
|
||||
Connection to If node "Check Condition" uses sourceIndex=0.
|
||||
Consider using branch="true" or branch="false" for better clarity.
|
||||
If node outputs: main[0]=TRUE branch, main[1]=FALSE branch.
|
||||
```
|
||||
|
||||
#### Test Coverage
|
||||
|
||||
- Added regression tests that reproduce the exact issue from #360
|
||||
- Verify warnings are generated for If and Switch nodes
|
||||
- Confirm existing smart parameter tests still pass
|
||||
|
||||
**Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en**
|
||||
|
||||
---
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
**Auto-Update Node Versions with Smart Migration**
|
||||
|
||||
@@ -82,7 +82,7 @@ ENV IS_DOCKER=true
|
||||
# To opt-out, uncomment the following line:
|
||||
# ENV N8N_MCP_TELEMETRY_DISABLED=true
|
||||
|
||||
# Expose HTTP port (default 3000, configurable via PORT environment variable at runtime)
|
||||
# Expose HTTP port
|
||||
EXPOSE 3000
|
||||
|
||||
# Set stop signal to SIGTERM (default, but explicit is better)
|
||||
@@ -90,7 +90,7 @@ STOPSIGNAL SIGTERM
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD sh -c 'curl -f http://127.0.0.1:${PORT:-3000}/health || exit 1'
|
||||
CMD curl -f http://127.0.0.1:3000/health || exit 1
|
||||
|
||||
# Optimized entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
@@ -20,19 +20,19 @@ services:
|
||||
image: n8n-mcp:latest
|
||||
container_name: n8n-mcp
|
||||
ports:
|
||||
- "${PORT:-3000}:${PORT:-3000}"
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- MCP_MODE=${MCP_MODE:-http}
|
||||
- AUTH_TOKEN=${AUTH_TOKEN}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- PORT=${PORT:-3000}
|
||||
- PORT=3000
|
||||
volumes:
|
||||
# Mount data directory for persistence
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -f http://localhost:$${PORT:-3000}/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -37,12 +37,11 @@ services:
|
||||
container_name: n8n-mcp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${MCP_PORT:-3000}:${MCP_PORT:-3000}"
|
||||
- "${MCP_PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- N8N_MODE=true
|
||||
- MCP_MODE=http
|
||||
- PORT=${MCP_PORT:-3000}
|
||||
- N8N_API_URL=http://n8n:5678
|
||||
- N8N_API_KEY=${N8N_API_KEY}
|
||||
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||
@@ -57,7 +56,7 @@ services:
|
||||
n8n:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -f http://localhost:$${MCP_PORT:-3000}/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
|
||||
# Port mapping
|
||||
ports:
|
||||
- "${PORT:-3000}:${PORT:-3000}"
|
||||
- "${PORT:-3000}:3000"
|
||||
|
||||
# Resource limits
|
||||
deploy:
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -f http://127.0.0.1:$${PORT:-3000}/health"]
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -59,10 +59,10 @@ docker compose up -d
|
||||
- n8n-mcp-data:/app/data
|
||||
|
||||
ports:
|
||||
- "${PORT:-3000}:${PORT:-3000}"
|
||||
|
||||
- "${PORT:-3000}:3000"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -f http://127.0.0.1:$${PORT:-3000}/health"]
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.22.7",
|
||||
"version": "2.22.0",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.22.7",
|
||||
"version": "2.22.0",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate release notes for the initial release
|
||||
* Used by GitHub Actions when no previous tag exists
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function generateInitialReleaseNotes(version) {
|
||||
try {
|
||||
// Get total commit count
|
||||
const commitCount = execSync('git rev-list --count HEAD', { encoding: 'utf8' }).trim();
|
||||
|
||||
// Generate release notes
|
||||
const releaseNotes = [
|
||||
'### 🎉 Initial Release',
|
||||
'',
|
||||
`This is the initial release of n8n-mcp v${version}.`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'**Release Statistics:**',
|
||||
`- Commit count: ${commitCount}`,
|
||||
'- First release setup'
|
||||
];
|
||||
|
||||
return releaseNotes.join('\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error generating initial release notes: ${error.message}`);
|
||||
return `Failed to generate initial release notes: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const version = process.argv[2];
|
||||
|
||||
if (!version) {
|
||||
console.error('Usage: generate-initial-release-notes.js <version>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releaseNotes = generateInitialReleaseNotes(version);
|
||||
console.log(releaseNotes);
|
||||
@@ -138,7 +138,6 @@ export async function handleUpdatePartialWorkflow(
|
||||
error: 'Failed to apply diff operations',
|
||||
details: {
|
||||
errors: diffResult.errors,
|
||||
warnings: diffResult.warnings,
|
||||
operationsApplied: diffResult.operationsApplied,
|
||||
applied: diffResult.applied,
|
||||
failed: diffResult.failed
|
||||
@@ -155,9 +154,6 @@ export async function handleUpdatePartialWorkflow(
|
||||
data: {
|
||||
valid: true,
|
||||
operationsToApply: input.operations.length
|
||||
},
|
||||
details: {
|
||||
warnings: diffResult.warnings
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -256,8 +252,7 @@ export async function handleUpdatePartialWorkflow(
|
||||
workflowName: updatedWorkflow.name,
|
||||
applied: diffResult.applied,
|
||||
failed: diffResult.failed,
|
||||
errors: diffResult.errors,
|
||||
warnings: diffResult.warnings
|
||||
errors: diffResult.errors
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -186,115 +186,7 @@ Please choose a different name.
|
||||
- Simply rename nodes with updateNode - no manual connection operations needed
|
||||
- Multiple renames in one call work atomically
|
||||
- Can rename a node and add/remove connections using the new name in the same batch
|
||||
- Use \`validateOnly: true\` to preview effects before applying
|
||||
|
||||
## Removing Properties with undefined
|
||||
|
||||
To remove a property from a node, set its value to \`undefined\` in the updates object. This is essential when migrating from deprecated properties or cleaning up optional configuration fields.
|
||||
|
||||
### Why Use undefined?
|
||||
- **Property removal vs. null**: Setting a property to \`undefined\` removes it completely from the node object, while \`null\` sets the property to a null value
|
||||
- **Validation constraints**: Some properties are mutually exclusive (e.g., \`continueOnFail\` and \`onError\`). Simply setting one without removing the other will fail validation
|
||||
- **Deprecated property migration**: When n8n deprecates properties, you must remove the old property before the new one will work
|
||||
|
||||
### Basic Property Removal
|
||||
\`\`\`javascript
|
||||
// Remove error handling configuration
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_123",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: { onError: undefined }
|
||||
}]
|
||||
});
|
||||
|
||||
// Remove disabled flag
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_456",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeId: "node_abc",
|
||||
updates: { disabled: undefined }
|
||||
}]
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Nested Property Removal
|
||||
Use dot notation to remove nested properties:
|
||||
\`\`\`javascript
|
||||
// Remove nested parameter
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_789",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "API Request",
|
||||
updates: { "parameters.authentication": undefined }
|
||||
}]
|
||||
});
|
||||
|
||||
// Remove entire array property
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_012",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: { "parameters.headers": undefined }
|
||||
}]
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Migrating from Deprecated Properties
|
||||
Common scenario: replacing \`continueOnFail\` with \`onError\`:
|
||||
\`\`\`javascript
|
||||
// WRONG: Setting only the new property leaves the old one
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_123",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: { onError: "continueErrorOutput" }
|
||||
}]
|
||||
});
|
||||
// Error: continueOnFail and onError are mutually exclusive
|
||||
|
||||
// CORRECT: Remove the old property first
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_123",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: {
|
||||
continueOnFail: undefined,
|
||||
onError: "continueErrorOutput"
|
||||
}
|
||||
}]
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Batch Property Removal
|
||||
Remove multiple properties in one operation:
|
||||
\`\`\`javascript
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_345",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "Data Processor",
|
||||
updates: {
|
||||
continueOnFail: undefined,
|
||||
alwaysOutputData: undefined,
|
||||
"parameters.legacy_option": undefined
|
||||
}
|
||||
}]
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### When to Use undefined
|
||||
- Removing deprecated properties during migration
|
||||
- Cleaning up optional configuration flags
|
||||
- Resolving mutual exclusivity validation errors
|
||||
- Removing stale or unnecessary node metadata
|
||||
- Simplifying node configuration`,
|
||||
- Use \`validateOnly: true\` to preview effects before applying`,
|
||||
parameters: {
|
||||
id: { type: 'string', required: true, description: 'Workflow ID to update' },
|
||||
operations: {
|
||||
@@ -331,13 +223,7 @@ n8n_update_partial_workflow({
|
||||
'// Vector Store setup: Connect embeddings and documents\nn8n_update_partial_workflow({id: "ai7", operations: [\n {type: "addConnection", source: "Embeddings OpenAI", target: "Pinecone Vector Store", sourceOutput: "ai_embedding"},\n {type: "addConnection", source: "Default Data Loader", target: "Pinecone Vector Store", sourceOutput: "ai_document"}\n]})',
|
||||
'// Connect Vector Store Tool to AI Agent (retrieval setup)\nn8n_update_partial_workflow({id: "ai8", operations: [\n {type: "addConnection", source: "Pinecone Vector Store", target: "Vector Store Tool", sourceOutput: "ai_vectorStore"},\n {type: "addConnection", source: "Vector Store Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})',
|
||||
'// Rewire AI Agent to use different language model\nn8n_update_partial_workflow({id: "ai9", operations: [{type: "rewireConnection", source: "AI Agent", from: "OpenAI Chat Model", to: "Anthropic Chat Model", sourceOutput: "ai_languageModel"}]})',
|
||||
'// Replace all AI tools for an agent\nn8n_update_partial_workflow({id: "ai10", operations: [\n {type: "removeConnection", source: "Old Tool 1", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "removeConnection", source: "Old Tool 2", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New HTTP Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New Code Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})',
|
||||
'\n// ============ REMOVING PROPERTIES EXAMPLES ============',
|
||||
'// Remove a simple property\nn8n_update_partial_workflow({id: "rm1", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {onError: undefined}}]})',
|
||||
'// Migrate from deprecated continueOnFail to onError\nn8n_update_partial_workflow({id: "rm2", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {continueOnFail: undefined, onError: "continueErrorOutput"}}]})',
|
||||
'// Remove nested property\nn8n_update_partial_workflow({id: "rm3", operations: [{type: "updateNode", nodeName: "API Request", updates: {"parameters.authentication": undefined}}]})',
|
||||
'// Remove multiple properties\nn8n_update_partial_workflow({id: "rm4", operations: [{type: "updateNode", nodeName: "Data Processor", updates: {continueOnFail: undefined, alwaysOutputData: undefined, "parameters.legacy_option": undefined}}]})',
|
||||
'// Remove entire array property\nn8n_update_partial_workflow({id: "rm5", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.headers": undefined}}]})'
|
||||
'// Replace all AI tools for an agent\nn8n_update_partial_workflow({id: "ai10", operations: [\n {type: "removeConnection", source: "Old Tool 1", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "removeConnection", source: "Old Tool 2", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New HTTP Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New Code Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})'
|
||||
],
|
||||
useCases: [
|
||||
'Rewire connections when replacing nodes',
|
||||
@@ -373,11 +259,7 @@ n8n_update_partial_workflow({
|
||||
'Connect language model BEFORE adding AI Agent to ensure validation passes',
|
||||
'Use targetIndex for fallback models (primary=0, fallback=1)',
|
||||
'Batch AI component connections in a single operation for atomicity',
|
||||
'Validate AI workflows after connection changes to catch configuration errors',
|
||||
'To remove properties, set them to undefined (not null) in the updates object',
|
||||
'When migrating from deprecated properties, remove the old property and add the new one in the same operation',
|
||||
'Use undefined to resolve mutual exclusivity validation errors between properties',
|
||||
'Batch multiple property removals in a single updateNode operation for efficiency'
|
||||
'Validate AI workflows after connection changes to catch configuration errors'
|
||||
],
|
||||
pitfalls: [
|
||||
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access',
|
||||
@@ -390,19 +272,12 @@ n8n_update_partial_workflow({
|
||||
'Use "updates" property for updateNode operations: {type: "updateNode", updates: {...}}',
|
||||
'Smart parameters (branch, case) only work with IF and Switch nodes - ignored for other node types',
|
||||
'Explicit sourceIndex overrides smart parameters (branch, case) if both provided',
|
||||
'**CRITICAL**: For If nodes, ALWAYS use branch="true"/"false" instead of sourceIndex. Using sourceIndex=0 for multiple connections will put them ALL on the TRUE branch (main[0]), breaking your workflow logic!',
|
||||
'**CRITICAL**: For Switch nodes, ALWAYS use case=N instead of sourceIndex. Using same sourceIndex for multiple connections will put them on the same case output.',
|
||||
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
||||
'replaceConnections overwrites entire connections object - all previous connections lost',
|
||||
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (isEmpty, isNotEmpty) automatically get singleValue:true added',
|
||||
'**Auto-sanitization runs on ALL nodes**: When ANY update is made, ALL nodes in the workflow are sanitized (not just modified ones)',
|
||||
'**Auto-sanitization cannot fix everything**: It fixes operator structures and missing metadata, but cannot fix broken connections or branch mismatches',
|
||||
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
|
||||
'Setting a property to null does NOT remove it - use undefined instead',
|
||||
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with undefined',
|
||||
'Removing a required property may cause validation errors - check node documentation first',
|
||||
'Nested property removal with dot notation only removes the specific nested field, not the entire parent object',
|
||||
'Array index notation (e.g., "parameters.headers[0]") is not supported - remove the entire array property instead'
|
||||
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated'
|
||||
],
|
||||
relatedTools: ['n8n_update_full_workflow', 'n8n_get_workflow', 'validate_workflow', 'tools_documentation']
|
||||
}
|
||||
|
||||
@@ -401,59 +401,7 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
config: Record<string, any>,
|
||||
result: EnhancedValidationResult
|
||||
): void {
|
||||
const url = String(config.url || '');
|
||||
const options = config.options || {};
|
||||
|
||||
// 1. Suggest alwaysOutputData for better error handling (node-level property)
|
||||
// Note: We can't check if it exists (it's node-level, not in parameters),
|
||||
// but we can suggest it as a best practice
|
||||
if (!result.suggestions.some(s => typeof s === 'string' && s.includes('alwaysOutputData'))) {
|
||||
result.suggestions.push(
|
||||
'Consider adding alwaysOutputData: true at node level (not in parameters) for better error handling. ' +
|
||||
'This ensures the node produces output even when HTTP requests fail, allowing downstream error handling.'
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Suggest responseFormat for API endpoints
|
||||
const lowerUrl = url.toLowerCase();
|
||||
const isApiEndpoint =
|
||||
// Subdomain patterns (api.example.com)
|
||||
/^https?:\/\/api\./i.test(url) ||
|
||||
// Path patterns with word boundaries to prevent false positives like "therapist", "restaurant"
|
||||
/\/api[\/\?]|\/api$/i.test(url) ||
|
||||
/\/rest[\/\?]|\/rest$/i.test(url) ||
|
||||
// Known API service domains
|
||||
lowerUrl.includes('supabase.co') ||
|
||||
lowerUrl.includes('firebase') ||
|
||||
lowerUrl.includes('googleapis.com') ||
|
||||
// Versioned API paths (e.g., example.com/v1, example.com/v2)
|
||||
/\.com\/v\d+/i.test(url);
|
||||
|
||||
if (isApiEndpoint && !options.response?.response?.responseFormat) {
|
||||
result.suggestions.push(
|
||||
'API endpoints should explicitly set options.response.response.responseFormat to "json" or "text" ' +
|
||||
'to prevent confusion about response parsing. Example: ' +
|
||||
'{ "options": { "response": { "response": { "responseFormat": "json" } } } }'
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Enhanced URL protocol validation for expressions
|
||||
if (url && url.startsWith('=')) {
|
||||
// Expression-based URL - check for common protocol issues
|
||||
const expressionContent = url.slice(1); // Remove = prefix
|
||||
const lowerExpression = expressionContent.toLowerCase();
|
||||
|
||||
// Check for missing protocol in expression (case-insensitive)
|
||||
if (expressionContent.startsWith('www.') ||
|
||||
(expressionContent.includes('{{') && !lowerExpression.includes('http'))) {
|
||||
result.warnings.push({
|
||||
type: 'invalid_value',
|
||||
property: 'url',
|
||||
message: 'URL expression appears to be missing http:// or https:// protocol',
|
||||
suggestion: 'Include protocol in your expression. Example: ={{ "https://" + $json.domain + ".com" }}'
|
||||
});
|
||||
}
|
||||
}
|
||||
// Examples removed - validation provides error messages and fixes instead
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -170,23 +170,10 @@ export class N8nApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists workflows from n8n instance.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of workflows
|
||||
*
|
||||
* @remarks
|
||||
* This method handles two response formats for backwards compatibility:
|
||||
* - Modern (n8n v0.200.0+): {data: Workflow[], nextCursor?: string}
|
||||
* - Legacy (older versions): Workflow[] (wrapped automatically)
|
||||
*
|
||||
* @see https://github.com/czlonkowski/n8n-mcp/issues/349
|
||||
*/
|
||||
async listWorkflows(params: WorkflowListParams = {}): Promise<WorkflowListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/workflows', { params });
|
||||
return this.validateListResponse<Workflow>(response.data, 'workflows');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
@@ -204,23 +191,10 @@ export class N8nApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists executions from n8n instance.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of executions
|
||||
*
|
||||
* @remarks
|
||||
* This method handles two response formats for backwards compatibility:
|
||||
* - Modern (n8n v0.200.0+): {data: Execution[], nextCursor?: string}
|
||||
* - Legacy (older versions): Execution[] (wrapped automatically)
|
||||
*
|
||||
* @see https://github.com/czlonkowski/n8n-mcp/issues/349
|
||||
*/
|
||||
async listExecutions(params: ExecutionListParams = {}): Promise<ExecutionListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/executions', { params });
|
||||
return this.validateListResponse<Execution>(response.data, 'executions');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
@@ -287,23 +261,10 @@ export class N8nApiClient {
|
||||
}
|
||||
|
||||
// Credential Management
|
||||
/**
|
||||
* Lists credentials from n8n instance.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of credentials
|
||||
*
|
||||
* @remarks
|
||||
* This method handles two response formats for backwards compatibility:
|
||||
* - Modern (n8n v0.200.0+): {data: Credential[], nextCursor?: string}
|
||||
* - Legacy (older versions): Credential[] (wrapped automatically)
|
||||
*
|
||||
* @see https://github.com/czlonkowski/n8n-mcp/issues/349
|
||||
*/
|
||||
async listCredentials(params: CredentialListParams = {}): Promise<CredentialListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/credentials', { params });
|
||||
return this.validateListResponse<Credential>(response.data, 'credentials');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
@@ -345,23 +306,10 @@ export class N8nApiClient {
|
||||
}
|
||||
|
||||
// Tag Management
|
||||
/**
|
||||
* Lists tags from n8n instance.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of tags
|
||||
*
|
||||
* @remarks
|
||||
* This method handles two response formats for backwards compatibility:
|
||||
* - Modern (n8n v0.200.0+): {data: Tag[], nextCursor?: string}
|
||||
* - Legacy (older versions): Tag[] (wrapped automatically)
|
||||
*
|
||||
* @see https://github.com/czlonkowski/n8n-mcp/issues/349
|
||||
*/
|
||||
async listTags(params: TagListParams = {}): Promise<TagListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/tags', { params });
|
||||
return this.validateListResponse<Tag>(response.data, 'tags');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
@@ -464,49 +412,4 @@ export class N8nApiClient {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes n8n API list responses.
|
||||
* Handles both modern format {data: [], nextCursor?: string} and legacy array format.
|
||||
*
|
||||
* @param responseData - Raw response data from n8n API
|
||||
* @param resourceType - Resource type for error messages (e.g., 'workflows', 'executions')
|
||||
* @returns Normalized response in modern format
|
||||
* @throws Error if response structure is invalid
|
||||
*/
|
||||
private validateListResponse<T>(
|
||||
responseData: any,
|
||||
resourceType: string
|
||||
): { data: T[]; nextCursor?: string | null } {
|
||||
// Validate response structure
|
||||
if (!responseData || typeof responseData !== 'object') {
|
||||
throw new Error(`Invalid response from n8n API for ${resourceType}: response is not an object`);
|
||||
}
|
||||
|
||||
// Handle legacy case where API returns array directly (older n8n versions)
|
||||
if (Array.isArray(responseData)) {
|
||||
logger.warn(
|
||||
`n8n API returned array directly instead of {data, nextCursor} object for ${resourceType}. ` +
|
||||
'Wrapping in expected format for backwards compatibility.'
|
||||
);
|
||||
return {
|
||||
data: responseData,
|
||||
nextCursor: null
|
||||
};
|
||||
}
|
||||
|
||||
// Validate expected format {data: [], nextCursor?: string}
|
||||
if (!Array.isArray(responseData.data)) {
|
||||
const keys = Object.keys(responseData).slice(0, 5);
|
||||
const keysPreview = keys.length < Object.keys(responseData).length
|
||||
? `${keys.join(', ')}...`
|
||||
: keys.join(', ');
|
||||
throw new Error(
|
||||
`Invalid response from n8n API for ${resourceType}: expected {data: [], nextCursor?: string}, ` +
|
||||
`got object with keys: [${keysPreview}]`
|
||||
);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,6 @@ const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
|
||||
export class WorkflowDiffEngine {
|
||||
// Track node name changes during operations for connection reference updates
|
||||
private renameMap: Map<string, string> = new Map();
|
||||
// Track warnings during operation processing
|
||||
private warnings: WorkflowDiffValidationError[] = [];
|
||||
|
||||
/**
|
||||
* Apply diff operations to a workflow
|
||||
@@ -49,9 +47,8 @@ export class WorkflowDiffEngine {
|
||||
request: WorkflowDiffRequest
|
||||
): Promise<WorkflowDiffResult> {
|
||||
try {
|
||||
// Reset tracking for this diff operation
|
||||
// Reset rename tracking for this diff operation
|
||||
this.renameMap.clear();
|
||||
this.warnings = [];
|
||||
|
||||
// Clone workflow to avoid modifying original
|
||||
const workflowCopy = JSON.parse(JSON.stringify(workflow));
|
||||
@@ -117,7 +114,6 @@ export class WorkflowDiffEngine {
|
||||
? 'Validation successful. All operations are valid.'
|
||||
: `Validation completed with ${errors.length} errors.`,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
warnings: this.warnings.length > 0 ? this.warnings : undefined,
|
||||
applied: appliedIndices,
|
||||
failed: failedIndices
|
||||
};
|
||||
@@ -130,7 +126,6 @@ export class WorkflowDiffEngine {
|
||||
operationsApplied: appliedIndices.length,
|
||||
message: `Applied ${appliedIndices.length} operations, ${failedIndices.length} failed (continueOnError mode)`,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
warnings: this.warnings.length > 0 ? this.warnings : undefined,
|
||||
applied: appliedIndices,
|
||||
failed: failedIndices
|
||||
};
|
||||
@@ -218,8 +213,7 @@ export class WorkflowDiffEngine {
|
||||
success: true,
|
||||
workflow: workflowCopy,
|
||||
operationsApplied,
|
||||
message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`,
|
||||
warnings: this.warnings.length > 0 ? this.warnings : undefined
|
||||
message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -691,24 +685,6 @@ export class WorkflowDiffEngine {
|
||||
sourceIndex = operation.case;
|
||||
}
|
||||
|
||||
// Validation: Warn if using sourceIndex with If/Switch nodes without smart parameters
|
||||
if (sourceNode && operation.sourceIndex !== undefined && operation.branch === undefined && operation.case === undefined) {
|
||||
if (sourceNode.type === 'n8n-nodes-base.if') {
|
||||
this.warnings.push({
|
||||
operation: -1, // Not tied to specific operation index in request
|
||||
message: `Connection to If node "${operation.source}" uses sourceIndex=${operation.sourceIndex}. ` +
|
||||
`Consider using branch="true" or branch="false" for better clarity. ` +
|
||||
`If node outputs: main[0]=TRUE branch, main[1]=FALSE branch.`
|
||||
});
|
||||
} else if (sourceNode.type === 'n8n-nodes-base.switch') {
|
||||
this.warnings.push({
|
||||
operation: -1, // Not tied to specific operation index in request
|
||||
message: `Connection to Switch node "${operation.source}" uses sourceIndex=${operation.sourceIndex}. ` +
|
||||
`Consider using case=N for better clarity (case=0 for first output, case=1 for second, etc.).`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { sourceOutput, sourceIndex };
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,6 @@ export interface WorkflowDiffResult {
|
||||
success: boolean;
|
||||
workflow?: any; // Updated workflow if successful
|
||||
errors?: WorkflowDiffValidationError[];
|
||||
warnings?: WorkflowDiffValidationError[]; // Non-blocking warnings (e.g., parameter suggestions)
|
||||
operationsApplied?: number;
|
||||
message?: string;
|
||||
applied?: number[]; // Indices of successfully applied operations (when continueOnError is true)
|
||||
|
||||
@@ -188,7 +188,6 @@ describe('handlers-workflow-diff', () => {
|
||||
operationsApplied: 1,
|
||||
message: 'Validation successful',
|
||||
errors: [],
|
||||
warnings: []
|
||||
});
|
||||
|
||||
const result = await handleUpdatePartialWorkflow(diffRequest, mockRepository);
|
||||
@@ -200,9 +199,6 @@ describe('handlers-workflow-diff', () => {
|
||||
valid: true,
|
||||
operationsToApply: 1,
|
||||
},
|
||||
details: {
|
||||
warnings: []
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockApiClient.updateWorkflow).not.toHaveBeenCalled();
|
||||
|
||||
@@ -802,335 +802,4 @@ describe('EnhancedConfigValidator', () => {
|
||||
expect(result.errors[0].property).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enhanceHttpRequestValidation', () => {
|
||||
it('should suggest alwaysOutputData for HTTP Request nodes', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true },
|
||||
{ name: 'method', type: 'options', required: false }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.suggestions).toContainEqual(
|
||||
expect.stringContaining('alwaysOutputData: true at node level')
|
||||
);
|
||||
expect(result.suggestions).toContainEqual(
|
||||
expect.stringContaining('ensures the node produces output even when HTTP requests fail')
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest responseFormat for API endpoint URLs', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET',
|
||||
options: {} // Empty options, no responseFormat
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true },
|
||||
{ name: 'method', type: 'options', required: false },
|
||||
{ name: 'options', type: 'collection', required: false }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.suggestions).toContainEqual(
|
||||
expect.stringContaining('responseFormat')
|
||||
);
|
||||
expect(result.suggestions).toContainEqual(
|
||||
expect.stringContaining('options.response.response.responseFormat')
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest responseFormat for Supabase URLs', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: 'https://xxciwnthnnywanbplqwg.supabase.co/rest/v1/messages',
|
||||
method: 'GET',
|
||||
options: {}
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
expect(result.suggestions).toContainEqual(
|
||||
expect.stringContaining('responseFormat')
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT suggest responseFormat when already configured', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET',
|
||||
options: {
|
||||
response: {
|
||||
response: {
|
||||
responseFormat: 'json'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true },
|
||||
{ name: 'options', type: 'collection', required: false }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
const responseFormatSuggestion = result.suggestions.find(
|
||||
(s: string) => s.includes('responseFormat')
|
||||
);
|
||||
expect(responseFormatSuggestion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should warn about missing protocol in expression-based URLs', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: '=www.{{ $json.domain }}.com',
|
||||
method: 'GET'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'invalid_value',
|
||||
property: 'url',
|
||||
message: expect.stringContaining('missing http:// or https://')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn about missing protocol in expressions with template markers', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: '={{ $json.domain }}/api/data',
|
||||
method: 'GET'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'invalid_value',
|
||||
property: 'url',
|
||||
message: expect.stringContaining('missing http:// or https://')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT warn when expression includes http protocol', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: '={{ "https://" + $json.domain + ".com" }}',
|
||||
method: 'GET'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
const urlWarning = result.warnings.find(
|
||||
(w: any) => w.property === 'url' && w.message.includes('protocol')
|
||||
);
|
||||
expect(urlWarning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT suggest responseFormat for non-API URLs', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: 'https://example.com/page.html',
|
||||
method: 'GET',
|
||||
options: {}
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
const responseFormatSuggestion = result.suggestions.find(
|
||||
(s: string) => s.includes('responseFormat')
|
||||
);
|
||||
expect(responseFormatSuggestion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should detect missing protocol in expressions with uppercase HTTP', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
url: '={{ "HTTP://" + $json.domain + ".com" }}',
|
||||
method: 'GET'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
// Should NOT warn because HTTP:// is present (case-insensitive)
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should NOT suggest responseFormat for false positive URLs', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const testUrls = [
|
||||
'https://example.com/therapist-directory',
|
||||
'https://restaurant-bookings.com/reserve',
|
||||
'https://forest-management.org/data'
|
||||
];
|
||||
|
||||
testUrls.forEach(url => {
|
||||
const config = {
|
||||
url,
|
||||
method: 'GET',
|
||||
options: {}
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
const responseFormatSuggestion = result.suggestions.find(
|
||||
(s: string) => s.includes('responseFormat')
|
||||
);
|
||||
expect(responseFormatSuggestion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should suggest responseFormat for case-insensitive API paths', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const testUrls = [
|
||||
'https://example.com/API/users',
|
||||
'https://example.com/Rest/data',
|
||||
'https://example.com/REST/v1/items'
|
||||
];
|
||||
|
||||
testUrls.forEach(url => {
|
||||
const config = {
|
||||
url,
|
||||
method: 'GET',
|
||||
options: {}
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
expect(result.suggestions).toContainEqual(
|
||||
expect.stringContaining('responseFormat')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null and undefined URLs gracefully', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const testConfigs = [
|
||||
{ url: null, method: 'GET' },
|
||||
{ url: undefined, method: 'GET' },
|
||||
{ url: '', method: 'GET' }
|
||||
];
|
||||
|
||||
testConfigs.forEach(config => {
|
||||
const properties = [
|
||||
{ name: 'url', type: 'string', required: true }
|
||||
];
|
||||
|
||||
expect(() => {
|
||||
EnhancedConfigValidator.validateWithMode(
|
||||
nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -413,242 +413,6 @@ describe('N8nApiClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Format Validation (PR #367)', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
describe('listWorkflows - validation', () => {
|
||||
it('should handle modern format with data and nextCursor', async () => {
|
||||
const response = { data: [{ id: '1', name: 'Test' }], nextCursor: 'abc123' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await client.listWorkflows();
|
||||
|
||||
expect(result).toEqual(response);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.nextCursor).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should wrap legacy array format and log warning', async () => {
|
||||
const workflows = [{ id: '1', name: 'Test' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: workflows });
|
||||
|
||||
const result = await client.listWorkflows();
|
||||
|
||||
expect(result).toEqual({ data: workflows, nextCursor: null });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('n8n API returned array directly')
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('workflows')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on null response', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: null });
|
||||
|
||||
await expect(client.listWorkflows()).rejects.toThrow(
|
||||
'Invalid response from n8n API for workflows: response is not an object'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on undefined response', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: undefined });
|
||||
|
||||
await expect(client.listWorkflows()).rejects.toThrow(
|
||||
'Invalid response from n8n API for workflows: response is not an object'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on string response', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: 'invalid' });
|
||||
|
||||
await expect(client.listWorkflows()).rejects.toThrow(
|
||||
'Invalid response from n8n API for workflows: response is not an object'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on number response', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: 42 });
|
||||
|
||||
await expect(client.listWorkflows()).rejects.toThrow(
|
||||
'Invalid response from n8n API for workflows: response is not an object'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on invalid structure with different keys', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { items: [], total: 10 } });
|
||||
|
||||
await expect(client.listWorkflows()).rejects.toThrow(
|
||||
'Invalid response from n8n API for workflows: expected {data: [], nextCursor?: string}, got object with keys: [items, total]'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when data is not an array', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { data: 'invalid' } });
|
||||
|
||||
await expect(client.listWorkflows()).rejects.toThrow(
|
||||
'Invalid response from n8n API for workflows: expected {data: [], nextCursor?: string}'
|
||||
);
|
||||
});
|
||||
|
||||
it('should limit exposed keys to first 5 when many keys present', async () => {
|
||||
const manyKeys = { items: [], total: 10, page: 1, limit: 20, hasMore: true, metadata: {} };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: manyKeys });
|
||||
|
||||
try {
|
||||
await client.listWorkflows();
|
||||
expect.fail('Should have thrown error');
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain('items, total, page, limit, hasMore...');
|
||||
expect(error.message).not.toContain('metadata');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listExecutions - validation', () => {
|
||||
it('should handle modern format with data and nextCursor', async () => {
|
||||
const response = { data: [{ id: '1' }], nextCursor: 'abc123' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await client.listExecutions();
|
||||
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('should wrap legacy array format and log warning', async () => {
|
||||
const executions = [{ id: '1' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: executions });
|
||||
|
||||
const result = await client.listExecutions();
|
||||
|
||||
expect(result).toEqual({ data: executions, nextCursor: null });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('executions')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on null response', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: null });
|
||||
|
||||
await expect(client.listExecutions()).rejects.toThrow(
|
||||
'Invalid response from n8n API for executions: response is not an object'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on invalid structure', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
await expect(client.listExecutions()).rejects.toThrow(
|
||||
'Invalid response from n8n API for executions'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when data is not an array', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { data: 'invalid' } });
|
||||
|
||||
await expect(client.listExecutions()).rejects.toThrow(
|
||||
'Invalid response from n8n API for executions'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCredentials - validation', () => {
|
||||
it('should handle modern format with data and nextCursor', async () => {
|
||||
const response = { data: [{ id: '1' }], nextCursor: 'abc123' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await client.listCredentials();
|
||||
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('should wrap legacy array format and log warning', async () => {
|
||||
const credentials = [{ id: '1' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: credentials });
|
||||
|
||||
const result = await client.listCredentials();
|
||||
|
||||
expect(result).toEqual({ data: credentials, nextCursor: null });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('credentials')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on null response', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: null });
|
||||
|
||||
await expect(client.listCredentials()).rejects.toThrow(
|
||||
'Invalid response from n8n API for credentials: response is not an object'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on invalid structure', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
await expect(client.listCredentials()).rejects.toThrow(
|
||||
'Invalid response from n8n API for credentials'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when data is not an array', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { data: 'invalid' } });
|
||||
|
||||
await expect(client.listCredentials()).rejects.toThrow(
|
||||
'Invalid response from n8n API for credentials'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTags - validation', () => {
|
||||
it('should handle modern format with data and nextCursor', async () => {
|
||||
const response = { data: [{ id: '1' }], nextCursor: 'abc123' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await client.listTags();
|
||||
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('should wrap legacy array format and log warning', async () => {
|
||||
const tags = [{ id: '1' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: tags });
|
||||
|
||||
const result = await client.listTags();
|
||||
|
||||
expect(result).toEqual({ data: tags, nextCursor: null });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('tags')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on null response', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: null });
|
||||
|
||||
await expect(client.listTags()).rejects.toThrow(
|
||||
'Invalid response from n8n API for tags: response is not an object'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on invalid structure', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
await expect(client.listTags()).rejects.toThrow(
|
||||
'Invalid response from n8n API for tags'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when data is not an array', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: { data: 'invalid' } });
|
||||
|
||||
await expect(client.listTags()).rejects.toThrow(
|
||||
'Invalid response from n8n API for tags'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecution', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
|
||||
@@ -1418,113 +1418,6 @@ describe('WorkflowDiffEngine', () => {
|
||||
expect(result.workflow!.connections['Switch']['main'][2][0].node).toBe('Handler');
|
||||
expect(result.workflow!.connections['Switch']['main'][1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('should warn when using sourceIndex with If node (issue #360)', async () => {
|
||||
const addIF: any = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Check Condition',
|
||||
type: 'n8n-nodes-base.if',
|
||||
position: [400, 300]
|
||||
}
|
||||
};
|
||||
|
||||
const addSuccess: any = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Success Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [600, 200]
|
||||
}
|
||||
};
|
||||
|
||||
const addError: any = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Error Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [600, 400]
|
||||
}
|
||||
};
|
||||
|
||||
// BAD: Using sourceIndex with If node (reproduces issue #360)
|
||||
const connectSuccess: any = {
|
||||
type: 'addConnection',
|
||||
source: 'Check Condition',
|
||||
target: 'Success Handler',
|
||||
sourceIndex: 0 // Should use branch="true" instead
|
||||
};
|
||||
|
||||
const connectError: any = {
|
||||
type: 'addConnection',
|
||||
source: 'Check Condition',
|
||||
target: 'Error Handler',
|
||||
sourceIndex: 0 // Should use branch="false" instead - both will end up in main[0]!
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [addIF, addSuccess, addError, connectSuccess, connectError]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should produce warnings
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings!.length).toBe(2);
|
||||
expect(result.warnings![0].message).toContain('Consider using branch="true" or branch="false"');
|
||||
expect(result.warnings![0].message).toContain('If node outputs: main[0]=TRUE branch, main[1]=FALSE branch');
|
||||
expect(result.warnings![1].message).toContain('Consider using branch="true" or branch="false"');
|
||||
|
||||
// Both connections end up in main[0] (the bug behavior)
|
||||
expect(result.workflow!.connections['Check Condition']['main'][0].length).toBe(2);
|
||||
expect(result.workflow!.connections['Check Condition']['main'][0][0].node).toBe('Success Handler');
|
||||
expect(result.workflow!.connections['Check Condition']['main'][0][1].node).toBe('Error Handler');
|
||||
});
|
||||
|
||||
it('should warn when using sourceIndex with Switch node', async () => {
|
||||
const addSwitch: any = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
position: [400, 300]
|
||||
}
|
||||
};
|
||||
|
||||
const addHandler: any = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [600, 300]
|
||||
}
|
||||
};
|
||||
|
||||
// BAD: Using sourceIndex with Switch node
|
||||
const connect: any = {
|
||||
type: 'addConnection',
|
||||
source: 'Switch',
|
||||
target: 'Handler',
|
||||
sourceIndex: 1 // Should use case=1 instead
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [addSwitch, addHandler, connect]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should produce warning
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings!.length).toBe(1);
|
||||
expect(result.warnings![0].message).toContain('Consider using case=N for better clarity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddConnection with sourceIndex (Phase 0 Fix)', () => {
|
||||
|
||||
Reference in New Issue
Block a user