Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.66.0] - 2026-07-25

### Fixed

- **Canvas groups no longer break workflow updates (n8n 2.28+).** n8n stores canvas groups on the workflow as `nodeGroups`, and validates them on every write — including writes that have nothing to do with grouping. Because the update payload dropped the field, n8n backfilled the stored groups and validated them against the submitted graph, so `n8n_update_partial_workflow`, `n8n_update_full_workflow`, autofix, rollback and version restore all failed with HTTP 400 on any grouped workflow as soon as a diff removed a grouped node or changed a group's connectivity (`Group "X" references node ID "..." that does not exist in the workflow.`). Groups are now carried through writes and reconciled with the finished graph: members a diff deleted are pruned, a group left empty is removed, and a group n8n refuses is ungrouped so the edit still lands. Nodes and connections are never altered to save a frame, and every adjustment is reported as a warning.
- **Node IDs are now immutable in `updateNode`.** An `updates: { id: ... }` payload silently orphaned canvas-group membership and pinned data. It is rejected with a message pointing at remove-and-re-add.
- **Version comparison notices grouping changes.** `compareVersions` ignored `nodeGroups`, so a group-only version looked identical to its predecessor.
- **`n8n_workflow_versions` rollback works without an instance context.** The handler resolved its API client only when an `InstanceContext` was supplied, skipping the environment-variable fallback every other tool uses. On a plain `N8N_API_URL` setup — the common single-instance case — `rollback` therefore always answered "n8n API not configured. Cannot perform rollback without API access.", on any workflow, while `list` and `get` worked because they read the local version store instead. Found while testing canvas groups; unrelated to them.

### Added

- **`setNodeGroups` diff operation** for `n8n_update_partial_workflow`: replaces the workflow's canvas groups, addressing members by name or by ID (`[]` ungroups everything). Whether a grouping is valid stays n8n's decision — its rejection is returned verbatim rather than second-guessed, and a group the caller just authored is never silently discarded.
- **`nodeGroups` on `n8n_create_workflow` and `n8n_update_full_workflow`.** Omitting the field on an update keeps the stored groups; passing `[]` ungroups everything.
- **Canvas groups in reads and validation.** `n8n_get_workflow` reports `nodeGroups` in `structure`, `filtered` (groups touching the requested nodes) and `active` (the published version's own groups, not the draft's); `n8n_deploy_template` forwards groups a template carries; `validate_workflow` warns about dangling members, empty groups, a node in two groups, and a trigger inside a group — always as warnings, never blocking a workflow.
- **Graceful degradation for older n8n.** Group support is discovered from the write itself rather than a version probe. n8n reports an unknown property as `request/body must NOT have additional properties` — without naming it — so the field is not blamed on the strength of that message: the workflow is sent again without `nodeGroups`, and only if that succeeds is the instance recorded as predating the field (n8n 2.28). A retry that fails too means something else in the body was wrong, and n8n's own error is returned unchanged. Group descriptions (n8n 2.32) are identifiable, because a rejection inside a group carries its path, and are stripped with a warning. Each limit is remembered per instance and warned about on every affected write.

## [2.65.2] - 2026-07-23

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.65.2",
"version": "2.66.0",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
83 changes: 73 additions & 10 deletions src/mcp/handlers-n8n-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
hasWebhookTrigger,
getWebhookUrl
} from '../services/n8n-validation';
import { nodeGroupsField, parseNodeGroupsInput } from '../services/node-groups';
import {
N8nApiError,
N8nNotFoundError,
Expand Down Expand Up @@ -449,6 +450,8 @@ const createWorkflowSchema = z.object({
executionTimeout: z.number().optional(),
errorWorkflow: z.string().optional(),
})).optional(),
// Validated by parseNodeGroupsInput() — see services/node-groups.ts
nodeGroups: z.any().optional(),
projectId: z.string().optional(),
});

Expand All @@ -458,6 +461,8 @@ const updateWorkflowSchema = z.object({
nodes: z.preprocess(normalizeMcpWorkflowNodes, z.array(z.any())).optional(),
connections: z.preprocess(normalizeMcpWorkflowConnections, z.record(z.string(), z.any())).optional(),
settings: z.preprocess(normalizeMcpJsonValue, z.any()).optional(),
// Validated by parseNodeGroupsInput() — see services/node-groups.ts
nodeGroups: z.any().optional(),
createBackup: z.boolean().optional(),
intent: z.string().optional(),
});
Expand Down Expand Up @@ -601,8 +606,20 @@ export async function handleCreateWorkflow(args: unknown, context?: InstanceCont
};
}

// Canvas groups are kept out of the spread so an ungrouped create sends no `nodeGroups` key
// at all: Zod emits an own `nodeGroups: undefined` for a caller that sent null.
const { nodeGroups: rawNodeGroups, ...createPayload } = input;
const nodeGroups = parseNodeGroupsInput(rawNodeGroups);
const groupWarnings: string[] = [];

// Create workflow (n8n API expects node types in FULL form)
const workflow = await client.createWorkflow(input);
const workflow = await client.createWorkflow(
nodeGroups !== undefined ? { ...createPayload, nodeGroups } : createPayload,
{
authoredGroups: new Set((nodeGroups ?? []).map(group => group.name)),
onWarning: message => groupWarnings.push(message),
}
);

// Defensive check: ensure the API returned a valid workflow with an ID
if (!workflow || !workflow.id) {
Expand All @@ -626,7 +643,8 @@ export async function handleCreateWorkflow(args: unknown, context?: InstanceCont
active: workflow.active,
nodeCount: workflow.nodes?.length || 0
},
message: `Workflow "${workflow.name}" created successfully with ID: ${workflow.id}. Use n8n_get_workflow with mode 'structure' to verify current state.`
message: `Workflow "${workflow.name}" created successfully with ID: ${workflow.id}. Use n8n_get_workflow with mode 'structure' to verify current state.`,
...(groupWarnings.length > 0 ? { details: { warnings: groupWarnings } } : {})
};
} catch (error) {
if (error instanceof z.ZodError) {
Expand Down Expand Up @@ -767,6 +785,8 @@ export async function handleGetWorkflowStructure(args: unknown, context?: Instan
isArchived: workflow.isArchived,
nodes: simplifiedNodes,
connections: workflow.connections,
// Canvas groups are part of the topology an editor sees, so structure mode reports them.
...nodeGroupsField(workflow.nodeGroups),
nodeCount: workflow.nodes.length,
connectionCount: Object.keys(workflow.connections).length
}
Expand Down Expand Up @@ -866,6 +886,13 @@ export async function handleGetWorkflowFiltered(args: unknown, context?: Instanc
const matchedKeys = new Set(matchedNodes.flatMap(node => [node.name, node.id]));
const notFound = nodeNames.filter(key => !matchedKeys.has(key));

// Only groups touching the requested nodes. Their nodeIds may reference nodes outside this
// response — filtered mode returns a slice of the workflow, not a valid whole.
const matchedIds = new Set(matchedNodes.map(node => node.id));
const touchedGroups = (workflow.nodeGroups ?? []).filter(group =>
Array.isArray(group?.nodeIds) && group.nodeIds.some(nodeId => matchedIds.has(nodeId))
);

return {
success: true,
data: {
Expand All @@ -874,6 +901,7 @@ export async function handleGetWorkflowFiltered(args: unknown, context?: Instanc
active: workflow.active,
isArchived: workflow.isArchived,
nodes: matchedNodes,
...nodeGroupsField(touchedGroups),
nodeCount: workflow.nodes.length,
returnedCount: matchedNodes.length,
...(notFound.length > 0 ? { notFound } : {})
Expand Down Expand Up @@ -944,6 +972,9 @@ export async function handleGetWorkflowActive(args: unknown, context?: InstanceC
versionName: activeVersion.name ?? null,
nodes: activeVersion.nodes,
connections: activeVersion.connections,
// The published version's own groups — NOT workflow.nodeGroups, which is the draft's
// and would describe frames around nodes that may not exist in this graph.
...nodeGroupsField(activeVersion.nodeGroups),
}
};
}
Expand All @@ -963,6 +994,8 @@ export async function handleGetWorkflowActive(args: unknown, context?: InstanceC
versionName: null,
nodes: workflow.nodes,
connections: workflow.connections,
// No draft/publish split here: the workflow body IS the running graph, so its groups apply.
...nodeGroupsField(workflow.nodeGroups),
}
};
}
Expand Down Expand Up @@ -1056,7 +1089,12 @@ export async function handleUpdateWorkflow(
// so a partial payload (e.g. { executionOrder: 'v0' }) doesn't drop untouched keys like
// timezone/errorWorkflow. A missing/null/non-object settings value leaves current settings
// untouched.
const { settings: settingsUpdate, ...nonSettingsUpdate } = updateData;
// Canvas groups are kept out of the spread for the same reason: Zod emits an own
// `nodeGroups: undefined` key when the caller sends null, and spreading that would wipe the
// stored groups. The contract is: key absent (or null) => keep the stored groups,
// `nodeGroups: []` => ungroup everything, a non-empty array => replace.
const { settings: settingsUpdate, nodeGroups: rawNodeGroups, ...nonSettingsUpdate } = updateData;
const nodeGroupsUpdate = parseNodeGroupsInput(rawNodeGroups);
const fullWorkflow = {
...current,
...nonSettingsUpdate
Expand All @@ -1069,8 +1107,12 @@ export async function handleUpdateWorkflow(
};
}

// Backup + structure validation only when the graph changed (nodes/connections).
if (updateData.nodes || updateData.connections) {
if (nodeGroupsUpdate !== undefined) {
fullWorkflow.nodeGroups = nodeGroupsUpdate;
}

// Backup + structure validation when the graph or its grouping changed.
if (updateData.nodes || updateData.connections || nodeGroupsUpdate !== undefined) {
// Create backup before modifying workflow (default: true)
if (createBackup !== false) {
try {
Expand Down Expand Up @@ -1105,8 +1147,14 @@ export async function handleUpdateWorkflow(
}
}

// Update workflow with the merged full payload
const workflow = await client.updateWorkflow(id, fullWorkflow as Partial<Workflow>);
// Update workflow with the merged full payload. Groups the caller supplied here are
// "authored": if n8n rejects one, that surfaces as an error rather than being ungrouped
// silently. Groups carried in from the GET degrade with a warning instead.
const groupWarnings: string[] = [];
const workflow = await client.updateWorkflow(id, fullWorkflow as Partial<Workflow>, {
authoredGroups: new Set((nodeGroupsUpdate ?? []).map(group => group.name)),
onWarning: message => groupWarnings.push(message),
});

// Track successful mutation
if (workflowBefore) {
Expand All @@ -1132,7 +1180,8 @@ export async function handleUpdateWorkflow(
active: workflow.active,
nodeCount: workflow.nodes?.length || 0
},
message: `Workflow "${workflow.name}" updated successfully. Use n8n_get_workflow with mode 'structure' to verify current state.`
message: `Workflow "${workflow.name}" updated successfully. Use n8n_get_workflow with mode 'structure' to verify current state.`,
...(groupWarnings.length > 0 ? { details: { warnings: groupWarnings } } : {})
};
} catch (error) {
// Track failed mutation
Expand Down Expand Up @@ -2626,7 +2675,12 @@ export async function handleWorkflowVersions(
};
}

const client = context ? getN8nApiClient(context) : null;
// Resolve the client the same way every other tool does. Gating on `context` skipped
// getN8nApiClient's environment-variable fallback, so on a plain N8N_API_URL setup — no
// instance context — `rollback` always answered "n8n API not configured" while `list`/`get`
// worked, because they read the local version store instead. Multi-tenant isolation is
// enforced inside getN8nApiClient and by the scope check above, not by this ternary.
const client = getN8nApiClient(context);
const versioningService = new WorkflowVersioningService(repository, client || undefined, getInstanceScopeId(context));

switch (input.mode) {
Expand Down Expand Up @@ -2925,11 +2979,17 @@ export async function handleDeployTemplate(

// Create workflow via API (always creates inactive)
// Deploy first, then fix - this ensures the workflow exists before we modify it
const templateGroupWarnings: string[] = [];
const createdWorkflow = await client.createWorkflow({
name: workflowName,
nodes: workflow.nodes,
connections: workflow.connections,
// Templates keep their node IDs through deployment (only typeVersion and credentials are
// touched), so any canvas groups they carry still address the right nodes.
...nodeGroupsField(workflow.nodeGroups),
settings: workflow.settings || { executionOrder: 'v1' }
}, {
onWarning: message => templateGroupWarnings.push(message)
});

// Get base URL for workflow link
Expand Down Expand Up @@ -2987,7 +3047,10 @@ export async function handleDeployTemplate(
templateId: input.templateId,
templateUrl: template.url || `https://n8n.io/workflows/${input.templateId}`,
autoFixStatus,
fixesApplied: fixesApplied.length > 0 ? fixesApplied : undefined
fixesApplied: fixesApplied.length > 0 ? fixesApplied : undefined,
// Canvas groups a template carried that this n8n could not store. Without this the tool
// would report an unqualified success while the template's frames were dropped.
warnings: templateGroupWarnings.length > 0 ? templateGroupWarnings : undefined
},
message: `Workflow "${createdWorkflow.name}" deployed successfully from template ${input.templateId}.${fixSummary} ${
requiredCredentials.length > 0
Expand Down
34 changes: 31 additions & 3 deletions src/mcp/handlers-workflow-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ const workflowDiffSchema = z.object({
settings: z.preprocess(normalizeMcpJsonValue, z.any()).optional(),
name: z.string().optional(),
tag: z.string().optional(),
// Canvas groups (setNodeGroups). Must be declared here: unknown keys are stripped, and a
// setNodeGroups op arriving without its payload would otherwise look like "ungroup everything".
nodeGroups: z.preprocess(normalizeMcpJsonValue, z.any()).optional(),
// Transfer operation
destinationProjectId: z.string().min(1).optional(),
// Aliases: LLMs often use "id" instead of "nodeId" — accept both
Expand Down Expand Up @@ -364,9 +367,19 @@ export async function handleUpdatePartialWorkflow(
// versionCounter / updatedAt — whichever the running n8n exposes). If
// unchanged, the body never persisted and rolling back would be both
// a wasted PUT and a misleading "(restored to prior state)" message.

// Canvas-group adjustments made while saving (a pruned member, a group n8n rejected).
// Groups this diff authored are passed through so n8n's rejection of one surfaces as an
// error instead of being quietly ungrouped.
const groupWarnings: string[] = [];
const groupWriteOptions = {
authoredGroups: new Set(diffResult.authoredGroupNames ?? []),
onWarning: (message: string) => groupWarnings.push(message),
};

let updatedWorkflow;
try {
updatedWorkflow = await client.updateWorkflow(input.id, diffResult.workflow!);
updatedWorkflow = await client.updateWorkflow(input.id, diffResult.workflow!, groupWriteOptions);
} catch (updateError) {
if (workflowBefore && !input.validateOnly) {
let serverState: any = null;
Expand Down Expand Up @@ -407,7 +420,11 @@ export async function handleUpdatePartialWorkflow(
let rollbackPerformed = false;
let rollbackErrorMessage: string | undefined;
try {
await client.updateWorkflow(input.id, workflowBefore);
// No authoredGroups here: restoring the graph matters, frames do not. If the snapshot's
// groups no longer fit the server state, they are dropped rather than failing the rollback.
await client.updateWorkflow(input.id, workflowBefore, {
onWarning: (message: string) => groupWarnings.push(message),
});
rollbackPerformed = true;
logger.warn('updateWorkflow failed; rolled back to prior state', {
workflowId: input.id,
Expand All @@ -430,6 +447,13 @@ export async function handleUpdatePartialWorkflow(
rollbackPerformed,
...(rollbackErrorMessage ? { rollbackError: rollbackErrorMessage } : {}),
...(workflowBefore.versionId ? { priorVersionId: workflowBefore.versionId } : {}),
// A rollback can have to drop a canvas group the server no longer accepts. That is a
// real change to the restored workflow, so it must not be lost just because this path
// ends in an error rather than the success response. Same shape as the success path's
// warnings, so a client can read details.warnings without branching on the outcome.
...(groupWarnings.length > 0
? { warnings: groupWarnings.map(message => ({ operation: -1, message })) }
: {}),
};
const suffix = rollbackPerformed
? ' (workflow restored to prior state)'
Expand Down Expand Up @@ -613,7 +637,7 @@ export async function handleUpdatePartialWorkflow(
applied: diffResult.applied,
failed: diffResult.failed,
errors: diffResult.errors,
warnings: mergeWarnings(diffResult.warnings, tagWarnings)
warnings: mergeWarnings(diffResult.warnings, [...tagWarnings, ...groupWarnings])
}
};
} catch (error) {
Expand Down Expand Up @@ -711,6 +735,10 @@ function inferIntentFromOperations(operations: any[]): string {
return `Rewire ${op.source || 'node'} from ${op.from || ''} to ${op.to || ''}`.trim();
case 'updateName':
return `Rename workflow to "${op.name || ''}"`;
case 'setNodeGroups':
return Array.isArray(op.nodeGroups) && op.nodeGroups.length === 0
? 'Remove all canvas groups'
: `Set canvas groups (${Array.isArray(op.nodeGroups) ? op.nodeGroups.length : 0})`;
case 'activateWorkflow':
return 'Activate workflow';
case 'deactivateWorkflow':
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/tool-docs/workflow_management/n8n-create-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export const n8nCreateWorkflowDoc: ToolDocumentation = {
name: { type: 'string', required: true, description: 'Workflow name' },
nodes: { type: 'array', required: true, description: 'Array of nodes with id, name, type, typeVersion, position, parameters' },
connections: { type: 'object', required: true, description: 'Node connections. Keys are source node names (not IDs)' },
settings: { type: 'object', description: 'Optional workflow settings (timezone, error handling, etc.)' }
settings: { type: 'object', description: 'Optional workflow settings (timezone, error handling, etc.)' },
nodeGroups: { type: 'array', description: 'Optional canvas groups (n8n 2.28+): [{name, nodeIds, description?}]. Members are node IDs from nodes[] and must form a connected run with no trigger among them. Dropped with a warning on n8n older than 2.28.' }
},
returns: 'Minimal summary (id, name, active, nodeCount) for token efficiency. Use n8n_get_workflow with mode "structure" to verify current state if needed.',
examples: [
Expand Down
Loading
Loading