fix: resolve multiple n8n_update_partial_workflow bugs (#635)

* fix: use correct MCP SDK API for server capabilities in test

getServerVersion() returns Implementation (name/version only), not the
full init result. Use client.getServerCapabilities() instead to access
server capabilities, fixing the CI typecheck failure.

Concieved by Romuald Członkowski - www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve multiple n8n_update_partial_workflow bugs (#592, #599, #610, #623, #624, #625, #629, #630, #633)

Phase 1 - Data loss prevention:
- Add missing unary operators (empty, notEmpty, exists, notExists) to sanitizer (#592)
- Preserve positional empty arrays in connections during removeNode/cleanStale (#610)
- Scope sanitization to modified nodes only, preventing unrelated node corruption
- Add empty body {} to activate/deactivate POST calls to fix 415 errors (#633)

Phase 2 - Error handling & response clarity:
- Serialize Zod errors to readable "path: message" strings (#630)
- Add saved:true/false field to all response paths (#625)
- Improve updateNode error hint with correct structure example (#623)
- Track removed node names for better removeConnection errors (#624)

Phase 3 - Connection & type fixes:
- Coerce sourceOutput/targetInput to String() consistently (#629)
- Accept numeric sourceOutput/targetInput at Zod schema level via transform

Phase 4 - Tag operations via dedicated API (#599):
- Track tags as tagsToAdd/tagsToRemove instead of mutating workflow.tags
- Orchestrate tag creation and association via listTags/createTag/updateWorkflowTags
- Reconcile conflicting add/remove for same tag (last operation wins)
- Tag failures produce warnings, not hard errors

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add v2.37.0 changelog entry

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve pre-existing integration test failures in CI

- Create new MCP Server instance per connection in test helpers (SDK 1.27+
  requires separate Protocol instance per connection)
- Normalize database paths with path.resolve() in shared-database singleton
  to prevent path mismatch errors across test files
- Add no-op catch handler to deferred initialization promise in server.ts
  to prevent unhandled rejection warnings
- Properly call mcpServer.shutdown() in test helper close() to release
  shared database references

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2026-03-14 17:27:33 +01:00
committed by GitHub
parent 248f859c49
commit 9590f751d2
15 changed files with 504 additions and 225 deletions

View File

@@ -5,7 +5,7 @@
import { z } from 'zod';
import { McpToolResponse } from '../types/n8n-api';
import { WorkflowDiffRequest, WorkflowDiffOperation } from '../types/workflow-diff';
import { WorkflowDiffRequest, WorkflowDiffOperation, WorkflowDiffValidationError } from '../types/workflow-diff';
import { WorkflowDiffEngine } from '../services/workflow-diff-engine';
import { getN8nApiClient } from './handlers-n8n-manager';
import { N8nApiError, getUserFriendlyErrorMessage } from '../utils/n8n-errors';
@@ -48,8 +48,8 @@ const workflowDiffSchema = z.object({
target: z.string().optional(),
from: z.string().optional(), // For rewireConnection
to: z.string().optional(), // For rewireConnection
sourceOutput: z.string().optional(),
targetInput: z.string().optional(),
sourceOutput: z.union([z.string(), z.number()]).transform(String).optional(),
targetInput: z.union([z.string(), z.number()]).transform(String).optional(),
sourceIndex: z.number().optional(),
targetIndex: z.number().optional(),
// Smart parameters (Phase 1 UX improvement)
@@ -178,11 +178,12 @@ export async function handleUpdatePartialWorkflow(
// Complete failure - return error
return {
success: false,
saved: false,
error: 'Failed to apply diff operations',
operationsApplied: diffResult.operationsApplied,
details: {
errors: diffResult.errors,
warnings: diffResult.warnings,
operationsApplied: diffResult.operationsApplied,
applied: diffResult.applied,
failed: diffResult.failed
}
@@ -265,6 +266,7 @@ export async function handleUpdatePartialWorkflow(
if (!skipValidation) {
return {
success: false,
saved: false,
error: errorMessage,
details: {
errors: structureErrors,
@@ -273,7 +275,7 @@ export async function handleUpdatePartialWorkflow(
applied: diffResult.applied,
recoveryGuidance: recoverySteps,
note: 'Operations were applied but created an invalid workflow structure. The workflow was NOT saved to n8n to prevent UI rendering errors.',
autoSanitizationNote: 'Auto-sanitization runs on all nodes during updates to fix operator structures and add missing metadata. However, it cannot fix all issues (e.g., broken connections, branch mismatches). Use the recovery guidance above to resolve remaining issues.'
autoSanitizationNote: 'Auto-sanitization runs on modified nodes during updates to fix operator structures and add missing metadata. However, it cannot fix all issues (e.g., broken connections, branch mismatches). Use the recovery guidance above to resolve remaining issues.'
}
};
}
@@ -289,6 +291,63 @@ export async function handleUpdatePartialWorkflow(
try {
const updatedWorkflow = await client.updateWorkflow(input.id, diffResult.workflow!);
// Handle tag operations via dedicated API (#599)
let tagWarnings: string[] = [];
if (diffResult.tagsToAdd?.length || diffResult.tagsToRemove?.length) {
try {
// Get existing tags from the updated workflow
const existingTags: Array<{ id: string; name: string }> = Array.isArray(updatedWorkflow.tags)
? updatedWorkflow.tags.map((t: any) => typeof t === 'object' ? { id: t.id, name: t.name } : { id: '', name: t })
: [];
// Resolve tag names to IDs
const allTags = await client.listTags();
const tagMap = new Map<string, string>();
for (const t of allTags.data) {
if (t.id) tagMap.set(t.name.toLowerCase(), t.id);
}
// Create any tags that don't exist yet
for (const tagName of (diffResult.tagsToAdd || [])) {
if (!tagMap.has(tagName.toLowerCase())) {
try {
const newTag = await client.createTag({ name: tagName });
if (newTag.id) tagMap.set(tagName.toLowerCase(), newTag.id);
} catch (createErr) {
tagWarnings.push(`Failed to create tag "${tagName}": ${createErr instanceof Error ? createErr.message : 'Unknown error'}`);
}
}
}
// Compute final tag set — resolve string-type tags via tagMap
const currentTagIds = new Set<string>();
for (const et of existingTags) {
if (et.id) {
currentTagIds.add(et.id);
} else {
const resolved = tagMap.get(et.name.toLowerCase());
if (resolved) currentTagIds.add(resolved);
}
}
for (const tagName of (diffResult.tagsToAdd || [])) {
const tagId = tagMap.get(tagName.toLowerCase());
if (tagId) currentTagIds.add(tagId);
}
for (const tagName of (diffResult.tagsToRemove || [])) {
const tagId = tagMap.get(tagName.toLowerCase());
if (tagId) currentTagIds.delete(tagId);
}
// Update workflow tags via dedicated API
await client.updateWorkflowTags(input.id, Array.from(currentTagIds));
} catch (tagError) {
tagWarnings.push(`Tag update failed: ${tagError instanceof Error ? tagError.message : 'Unknown error'}`);
logger.warn('Tag operations failed (non-blocking)', tagError);
}
}
// Handle activation/deactivation if requested
let finalWorkflow = updatedWorkflow;
let activationMessage = '';
@@ -319,6 +378,7 @@ export async function handleUpdatePartialWorkflow(
logger.error('Failed to activate workflow after update', activationError);
return {
success: false,
saved: true,
error: 'Workflow updated successfully but activation failed',
details: {
workflowUpdated: true,
@@ -334,6 +394,7 @@ export async function handleUpdatePartialWorkflow(
logger.error('Failed to deactivate workflow after update', deactivationError);
return {
success: false,
saved: true,
error: 'Workflow updated successfully but deactivation failed',
details: {
workflowUpdated: true,
@@ -363,6 +424,7 @@ export async function handleUpdatePartialWorkflow(
return {
success: true,
saved: true,
data: {
id: finalWorkflow.id,
name: finalWorkflow.name,
@@ -375,7 +437,7 @@ export async function handleUpdatePartialWorkflow(
applied: diffResult.applied,
failed: diffResult.failed,
errors: diffResult.errors,
warnings: diffResult.warnings
warnings: mergeWarnings(diffResult.warnings, tagWarnings)
}
};
} catch (error) {
@@ -413,7 +475,9 @@ export async function handleUpdatePartialWorkflow(
return {
success: false,
error: 'Invalid input',
details: { errors: error.errors }
details: {
errors: error.errors.map(e => `${e.path.join('.')}: ${e.message}`)
}
};
}
@@ -425,6 +489,21 @@ export async function handleUpdatePartialWorkflow(
}
}
/**
* Merge diff engine warnings with tag operation warnings into a single array.
* Returns undefined when there are no warnings to keep the response clean.
*/
function mergeWarnings(
diffWarnings: WorkflowDiffValidationError[] | undefined,
tagWarnings: string[]
): WorkflowDiffValidationError[] | undefined {
const merged: WorkflowDiffValidationError[] = [
...(diffWarnings || []),
...tagWarnings.map(w => ({ operation: -1, message: w }))
];
return merged.length > 0 ? merged : undefined;
}
/**
* Infer intent from operations when not explicitly provided
*/