mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-21 20:20:03 +02:00
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:
parent
91130ee0e9
commit
db439a22b6
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user