diff --git a/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts index 170cd7b5b34..d1ba174c89e 100644 --- a/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts @@ -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', diff --git a/packages/cli/src/modules/mcp/__tests__/workflow-operations.test.ts b/packages/cli/src/modules/mcp/__tests__/workflow-operations.test.ts index d9d8da06417..dd9a00838d1 100644 --- a/packages/cli/src/modules/mcp/__tests__/workflow-operations.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/workflow-operations.test.ts @@ -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' } }); + }); }); }); diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts index a96d671d5b4..a1e3e6df56a 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts @@ -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. diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts index b8c0b8aeeb6..53248f42961 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts @@ -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', } diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts index 4ae54fb5053..772a2ff283d 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts @@ -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 => @@ -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 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, }; }