feat(core): Add setNodeDescription op to MCP update_workflow tool

Lets the agent set/update a node's guided-tour summary and rationale
when editing an existing workflow, mirroring the nodeDescriptions
support already on create_workflow_from_code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Angel Moreno 2026-08-12 14:40:53 +02:00
parent 91130ee0e9
commit db439a22b6
5 changed files with 185 additions and 1 deletions

View File

@ -1905,6 +1905,51 @@ describe('update-workflow MCP tool', () => {
);
});
test('setNodeDescription persists into meta.nodeDescriptions keyed by node id', async () => {
await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'setNodeDescription',
nodeName: 'A',
summary: 'Fetches the latest orders.',
rationale: 'Polling was chosen because the API has no webhook support.',
},
],
});
expect(updateMock.mock.calls[0][1].meta).toEqual(
expect.objectContaining({
nodeDescriptions: {
a: {
summary: 'Fetches the latest orders.',
rationale: 'Polling was chosen because the API has no webhook support.',
},
},
}),
);
});
test('setNodeDescription preserves an existing description on another node', async () => {
const existing = buildExistingWorkflow();
existing.meta = { nodeDescriptions: { b: { summary: 'Existing B summary' } } };
findWorkflowMock.mockResolvedValue(existing);
await callHandler({
workflowId: 'wf-1',
operations: [{ type: 'setNodeDescription', nodeName: 'A', summary: 'New A summary' }],
});
expect(updateMock.mock.calls[0][1].meta).toEqual(
expect.objectContaining({
nodeDescriptions: {
a: { summary: 'New A summary' },
b: { summary: 'Existing B summary' },
},
}),
);
});
test('broadcasts workflow update on success', async () => {
await callHandler({
workflowId: 'wf-1',

View File

@ -704,6 +704,83 @@ describe('applyOperations', () => {
});
});
describe('setNodeDescription', () => {
test('sets summary and rationale on the node id, keyed by name', () => {
const result = applyOperations(baseWorkflow(), [
{
type: 'setNodeDescription',
nodeName: 'A',
summary: 'Fetches the latest orders.',
rationale: 'Polling was chosen because the API has no webhook support.',
},
]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.workflow.nodeDescriptions).toEqual({
a: {
summary: 'Fetches the latest orders.',
rationale: 'Polling was chosen because the API has no webhook support.',
},
});
});
test('sets summary only when rationale is omitted', () => {
const result = applyOperations(baseWorkflow(), [
{ type: 'setNodeDescription', nodeName: 'A', summary: 'Fetches the latest orders.' },
]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.workflow.nodeDescriptions).toEqual({
a: { summary: 'Fetches the latest orders.' },
});
});
test("preserves other nodes' descriptions when setting one", () => {
const wf = baseWorkflow();
const result = applyOperations(wf, [
{ type: 'setNodeDescription', nodeName: 'A', summary: 'Summary A' },
{ type: 'setNodeDescription', nodeName: 'B', summary: 'Summary B' },
]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.workflow.nodeDescriptions).toEqual({
a: { summary: 'Summary A' },
b: { summary: 'Summary B' },
});
});
test('overwrites an existing description for the same node, clearing rationale when omitted', () => {
const wf = {
...baseWorkflow(),
nodeDescriptions: { a: { summary: 'Old summary', rationale: 'Old rationale' } },
};
const result = applyOperations(wf, [
{ type: 'setNodeDescription', nodeName: 'A', summary: 'New summary' },
]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.workflow.nodeDescriptions).toEqual({ a: { summary: 'New summary' } });
});
test('rejects when node does not exist', () => {
const result = applyOperations(baseWorkflow(), [
{ type: 'setNodeDescription', nodeName: 'Missing', summary: 'x' },
]);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.opIndex).toBe(0);
expect(result.error).toContain("'Missing' not found");
});
test('schema requires summary', () => {
const parsed = partialUpdateOperationSchema.safeParse({
type: 'setNodeDescription',
nodeName: 'A',
});
expect(parsed.success).toBe(false);
});
});
describe('setWorkflowMetadata', () => {
test('updates name and description', () => {
const result = applyOperations(baseWorkflow(), [
@ -1675,5 +1752,15 @@ describe('applyOperations', () => {
} as never);
expect(slice.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a'] }]);
});
test('carries nodeDescriptions through from meta', () => {
const slice = toWorkflowSlice({
name: 'wf',
nodes: [],
connections: {},
meta: { nodeDescriptions: { a: { summary: 'Does a thing' } } },
} as never);
expect(slice.nodeDescriptions).toEqual({ a: { summary: 'Does a thing' } });
});
});
});

View File

@ -100,7 +100,7 @@ To build n8n workflows${WORKFLOWS_ONLY_CLAUSE}, follow these steps in order:
9. Create: Call ${MCP_CREATE_WORKFLOW_FROM_CODE_TOOL.toolName} with the validated code to save the workflow to n8n. Include a short \`description\` (1-2 sentences, max 255 chars) summarizing what the workflow does — this helps users find and understand their workflows. Always pass \`nodeDescriptions\` with an entry for every node, keyed by node name: a \`summary\` (1-2 plain sentences on what the node does in this workflow) and a \`rationale\` (one sentence on why that node or approach was chosen over the alternative, e.g. webhook vs polling). These power a guided tour shown to the user on the canvas — write plainly, no marketing tone.
10. Update: Call ${MCP_UPDATE_WORKFLOW_TOOL.toolName} with the workflow ID and a list of operations (addNode, removeNode, updateNodeParameters, setNodeParameter, renameNode, addConnection, removeConnection, setNodeCredential, setNodePosition, setNodeDisabled, setNodeSettings, setWorkflowMetadata, setWorkflowSettings). The whole batch is atomic: if any op fails the workflow is unchanged. To modify an existing node's configuration, use updateNodeParameters or setNodeParameter — do NOT use removeNode followed by addNode for the same node, as this disconnects any attached sub-nodes (LLM models, memory, tools) and they will not be re-attached automatically. Use setNodeSettings to change a node's execution behavior (onError, retryOnFail, maxTries, waitBetweenTries, alwaysOutputData, executeOnce); for sub-nodes (LLM model, memory, tools) this is the only way to set onError, because the canvas UI does not expose that setting for them.
10. Update: Call ${MCP_UPDATE_WORKFLOW_TOOL.toolName} with the workflow ID and a list of operations (addNode, removeNode, updateNodeParameters, setNodeParameter, renameNode, addConnection, removeConnection, setNodeCredential, setNodePosition, setNodeDisabled, setNodeSettings, setNodeDescription, setWorkflowMetadata, setWorkflowSettings). The whole batch is atomic: if any op fails the workflow is unchanged. To modify an existing node's configuration, use updateNodeParameters or setNodeParameter — do NOT use removeNode followed by addNode for the same node, as this disconnects any attached sub-nodes (LLM models, memory, tools) and they will not be re-attached automatically. Use setNodeSettings to change a node's execution behavior (onError, retryOnFail, maxTries, waitBetweenTries, alwaysOutputData, executeOnce); for sub-nodes (LLM model, memory, tools) this is the only way to set onError, because the canvas UI does not expose that setting for them. Use setNodeDescription to add or update a node's guided-tour text: a \`summary\` (1-2 plain sentences on what the node does in this workflow) and an optional \`rationale\` (one sentence on why that node or approach was chosen). Add one whenever you addNode in the same batch, and update it whenever a later edit changes what a node does or why.
11. Archive: Call ${MCP_ARCHIVE_WORKFLOW_TOOL.toolName} with the workflow ID.

View File

@ -70,6 +70,7 @@ const baseOperationTypes = [
'setNodePosition',
'setNodeDisabled',
'setNodeSettings',
'setNodeDescription',
'setWorkflowMetadata',
'setWorkflowSettings',
'addTags',
@ -174,6 +175,18 @@ const buildOperationInputSchema = (canvasGroupsEnabled: boolean) =>
settings: combinedSettingsInputSchema
.optional()
.describe('For setNodeSettings or setWorkflowSettings.'),
summary: z
.string()
.optional()
.describe(
'For setNodeDescription: 1-2 plain sentences on what this node does in this workflow.',
),
rationale: z
.string()
.optional()
.describe(
'For setNodeDescription: one sentence on why this node or approach was chosen over the alternative (e.g. webhook vs polling). Omit to clear.',
),
name: z
.string()
.max(128)
@ -890,6 +903,9 @@ export const createUpdateWorkflowTool = (
meta: hasNonTagOperations
? {
...(existingWorkflow.meta ?? {}),
...(result.workflow.nodeDescriptions !== undefined
? { nodeDescriptions: result.workflow.nodeDescriptions }
: {}),
aiBuilderAssisted: true,
builderVariant: 'mcp',
}

View File

@ -8,6 +8,7 @@ import type {
IWorkflowGroup,
IWorkflowSettings,
NodeConnectionType,
WorkflowNodeDescriptions,
} from 'n8n-workflow';
import {
GROUP_DESCRIPTION_MAX_LENGTH,
@ -266,6 +267,17 @@ export const partialUpdateOperationSchema = z.discriminatedUnion('type', [
name: z.string().max(128).optional(),
description: z.string().max(255).optional(),
}),
z.object({
type: z.literal('setNodeDescription'),
nodeName: z.string().describe('Name of the existing node to update.'),
summary: z.string().describe('1-2 plain sentences on what this node does in this workflow.'),
rationale: z
.string()
.optional()
.describe(
'One sentence on why this node or approach was chosen over the alternative (e.g. webhook vs polling). Omit to clear.',
),
}),
z.object({
type: z.literal('setWorkflowSettings'),
settings: workflowSettingsInputSchema.describe(
@ -387,6 +399,8 @@ interface WorkflowSlice {
nodeGroups?: IWorkflowGroup[];
/** Existing tag names on the workflow. Undefined when not loaded; tag ops require this. */
tagNames?: string[];
/** Guided-tour descriptions keyed by node id. Undefined when the workflow has none. */
nodeDescriptions?: WorkflowNodeDescriptions;
}
export interface SkippedOperation {
@ -445,6 +459,9 @@ const cloneWorkflow = (workflow: WorkflowSlice): WorkflowSlice => ({
settings: workflow.settings ? structuredClone(workflow.settings) : undefined,
nodeGroups: workflow.nodeGroups ? structuredClone(workflow.nodeGroups) : undefined,
tagNames: workflow.tagNames ? [...workflow.tagNames] : undefined,
nodeDescriptions: workflow.nodeDescriptions
? structuredClone(workflow.nodeDescriptions)
: undefined,
});
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
@ -935,6 +952,23 @@ const handleSetNodeSettings: OpHandler<'setNodeSettings'> = (op, ctx) => {
return null;
};
const handleSetNodeDescription: OpHandler<'setNodeDescription'> = (op, ctx) => {
const node = ctx.nodeByName.get(op.nodeName);
if (!node) {
return `node '${op.nodeName}' not found`;
}
ctx.workflow.nodeDescriptions = {
...(ctx.workflow.nodeDescriptions ?? {}),
[node.id]: {
summary: op.summary,
...(op.rationale !== undefined ? { rationale: op.rationale } : {}),
},
};
return null;
};
const handleSetWorkflowMetadata: OpHandler<'setWorkflowMetadata'> = (op, ctx) => {
if (op.name !== undefined) {
ctx.workflow.name = op.name;
@ -1119,6 +1153,7 @@ const OPERATION_HANDLERS: { [K in PartialUpdateOperation['type']]: OpHandler<K>
setNodePosition: handleSetNodePosition,
setNodeDisabled: handleSetNodeDisabled,
setNodeSettings: handleSetNodeSettings,
setNodeDescription: handleSetNodeDescription,
setWorkflowMetadata: handleSetWorkflowMetadata,
setWorkflowSettings: handleSetWorkflowSettings,
setNodeGroups: handleSetNodeGroups,
@ -1219,5 +1254,6 @@ export function toWorkflowSlice(
settings: workflow.settings,
nodeGroups: workflow.nodeGroups,
tagNames,
nodeDescriptions: workflow.meta?.nodeDescriptions,
};
}