feat(core): Remove the N8N_MCP_CANVAS_GROUPS_ENABLED feature flag (no-changelog) (#38645)

Co-authored-by: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Miguel Ángel Moreno
2026-09-17 09:11:18 +00:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 39834a75d0
commit 087d134036
29 changed files with 1090 additions and 1625 deletions
-5
View File
@@ -15,11 +15,6 @@ N8N_LOG_LEVEL=debug
N8N_AI_ENABLED=true N8N_AI_ENABLED=true
N8N_ENABLED_MODULES=instance-ai N8N_ENABLED_MODULES=instance-ai
# Uncomment when calibrating MCP grouping cases (--build-via-mcp): canvas-group
# support in the MCP tools is PostHog-gated and PostHog is unreachable locally
# (fails closed), so the env var is the only lever. Mirrors test-evals-mcp.yml.
# N8N_MCP_CANVAS_GROUPS_ENABLED=true
# Model key for the builder and the eval helper (mock generation, verifier, # Model key for the builder and the eval helper (mock generation, verifier,
# user-proxy, expectation judge). When the instance runs without the proxy, the # user-proxy, expectation judge). When the instance runs without the proxy, the
# model client reads ANTHROPIC_API_KEY directly, so export that too if you go # model client reads ANTHROPIC_API_KEY directly, so export that too if you go
-3
View File
@@ -192,14 +192,11 @@ jobs:
# agents registers the agent-builder MCP tools so the suite measures # agents registers the agent-builder MCP tools so the suite measures
# workflow building with the production tool surface, catching # workflow building with the production tool surface, catching
# tool-choice regressions the extra tools could introduce. # tool-choice regressions the extra tools could introduce.
# MCP canvas-group support is PostHog-gated and PostHog is unreachable
# here (fails closed), so the env var force-enables it;
for i in "${!PORTS[@]}"; do for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}" port="${PORTS[$i]}"
docker run -d --name "n8n-eval-mcp-$((i + 1))" \ docker run -d --name "n8n-eval-mcp-$((i + 1))" \
-e E2E_TESTS=true \ -e E2E_TESTS=true \
-e N8N_ENABLED_MODULES=instance-ai,agents \ -e N8N_ENABLED_MODULES=instance-ai,agents \
-e N8N_MCP_CANVAS_GROUPS_ENABLED=true \
-e N8N_AI_ENABLED=true \ -e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \ -e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \
-e N8N_AI_ASSISTANT_BASE_URL="" \ -e N8N_AI_ASSISTANT_BASE_URL="" \
-1
View File
@@ -688,7 +688,6 @@ export {
MCP_APPS_FLAG, MCP_APPS_FLAG,
MCP_APPS_VARIANT_CONTROL, MCP_APPS_VARIANT_CONTROL,
MCP_APPS_VARIANT_ENABLED, MCP_APPS_VARIANT_ENABLED,
MCP_CANVAS_GROUPS_FLAG,
MCP_INSTANCE_CONTEXT_FLAG, MCP_INSTANCE_CONTEXT_FLAG,
MCP_AGENT_SCOPES, MCP_AGENT_SCOPES,
MCP_INSTANCE_SCOPES, MCP_INSTANCE_SCOPES,
@@ -10,12 +10,6 @@ export const MCP_APPS_FLAG = '087_mcp_apps';
export const MCP_APPS_VARIANT_CONTROL = 'control'; export const MCP_APPS_VARIANT_CONTROL = 'control';
export const MCP_APPS_VARIANT_ENABLED = 'variant'; export const MCP_APPS_VARIANT_ENABLED = 'variant';
// PostHog boolean rollout flag id gating Canvas node-group support in the MCP
// workflow-builder tools (group docs in the SDK reference, group handling in
// the create/validate/update tools). `true` enables; anything else keeps the
// current behaviour.
export const MCP_CANVAS_GROUPS_FLAG = '102_mcp_canvas_groups';
// Rollout flag for the instance-context read surface on the MCP server: the // Rollout flag for the instance-context read surface on the MCP server: the
// activity tools and the node-usage tool. Separate from the Instance AI read // activity tools and the node-usage tool. Separate from the Instance AI read
// flag so the two surfaces roll out independently against the same data. // flag so the two surfaces roll out independently against the same data.
@@ -206,14 +206,6 @@ export class EndpointsConfig {
@Env('N8N_MCP_APPS_ENABLED') @Env('N8N_MCP_APPS_ENABLED')
mcpAppsEnabled: boolean = false; mcpAppsEnabled: boolean = false;
/**
* Force-enable Canvas node-group support in the MCP workflow-builder tools.
* Acts as an operator-level override of the PostHog rollout flag.
* Cannot force-disable: setting this to `false` falls back to PostHog.
*/
@Env('N8N_MCP_CANVAS_GROUPS_ENABLED')
mcpCanvasGroupsEnabled: boolean = false;
/** /**
* Force-enable the instance-context read surface on the MCP server: the * Force-enable the instance-context read surface on the MCP server: the
* activity tools and the node-usage tool. * activity tools and the node-usage tool.
-1
View File
@@ -295,7 +295,6 @@ describe('GlobalConfig', () => {
mcp: 'mcp', mcp: 'mcp',
mcpAppsEnabled: false, mcpAppsEnabled: false,
mcpBuilderEnabled: true, mcpBuilderEnabled: true,
mcpCanvasGroupsEnabled: false,
mcpInstanceContextEnabled: false, mcpInstanceContextEnabled: false,
mcpMaxRegisteredClients: 5000, mcpMaxRegisteredClients: 5000,
mcpTest: 'mcp-test', mcpTest: 'mcp-test',
@@ -1,11 +1,6 @@
import { GROUP_DESCRIPTION_MAX_LENGTH, NODE_GROUPING_RULES } from 'n8n-workflow'; import { GROUP_DESCRIPTION_MAX_LENGTH, NODE_GROUPING_RULES } from 'n8n-workflow';
import { import { GROUPING_GUIDANCE, NODE_GROUPS_REFERENCE, SDK_LANGUAGE_REFERENCE } from './sdk-language';
GROUPING_GUIDANCE,
NODE_GROUPS_REFERENCE,
SDK_LANGUAGE_REFERENCE,
buildSdkLanguageReference,
} from './sdk-language';
import { import {
SDK_METHODS, SDK_METHODS,
FORBIDDEN_NODE_TYPES, FORBIDDEN_NODE_TYPES,
@@ -107,23 +102,6 @@ describe('SDK_LANGUAGE_REFERENCE rendering', () => {
}); });
}); });
describe('buildSdkLanguageReference', () => {
it('includes the groups docs by default', () => {
expect(buildSdkLanguageReference()).toBe(buildSdkLanguageReference({ includeGroups: true }));
expect(buildSdkLanguageReference()).toContain(NODE_GROUPS_REFERENCE);
});
it('omits only the groups docs when includeGroups is false', () => {
const withoutGroups = buildSdkLanguageReference({ includeGroups: false });
expect(withoutGroups).not.toContain('## Node groups');
// The rest of the reference is intact.
expect(withoutGroups).toContain('restricted subset of TypeScript');
expect(withoutGroups).toContain('## Forbidden constructs');
expect(withoutGroups).toContain('## Where to put runtime logic');
});
});
describe('NODE_GROUPS_REFERENCE', () => { describe('NODE_GROUPS_REFERENCE', () => {
it('explains what a group is and that it still matters', () => { it('explains what a group is and that it still matters', () => {
// A build called grouping "a finishing touch" and skipped it, echoing the word // A build called grouping "a finishing touch" and skipped it, echoing the word
@@ -116,9 +116,8 @@ ${renderRulesLines()}
/** /**
* Grouping judgement guidance: *when* to group — the rules that make a group * Grouping judgement guidance: *when* to group — the rules that make a group
* valid live in `NODE_GROUPS_REFERENCE`. MCP appends it to the technique list * valid live in `NODE_GROUPS_REFERENCE`. MCP appends it to the technique list;
* only when the canvas-groups flag is on; Instance AI always materializes it * Instance AI materializes it into the knowledge base.
* into the knowledge base.
*/ */
export const GROUPING_GUIDANCE = `## Grouping export const GROUPING_GUIDANCE = `## Grouping
@@ -152,14 +151,8 @@ Examples:
Read the node groups reference for the exact rules before creating groups.`; Read the node groups reference for the exact rules before creating groups.`;
/** /** Render the full language reference, node-groups section included. */
* Render the full language reference. The node-groups section is included by export function buildSdkLanguageReference(): string {
* default (Instance AI's knowledge base); the MCP SDK reference passes its
* `canvasGroupsEnabled` flag state as `includeGroups`.
*/
export function buildSdkLanguageReference(options: { includeGroups?: boolean } = {}): string {
const { includeGroups = true } = options;
return `# Workflow SDK language reference return `# Workflow SDK language reference
SDK builder code is a **restricted subset of TypeScript**, not a Code node and SDK builder code is a **restricted subset of TypeScript**, not a Code node and
@@ -173,7 +166,9 @@ ${renderMethodLines()}
${SAFE_METHODS_SENTENCE} ${SAFE_METHODS_SENTENCE}
${includeGroups ? `${NODE_GROUPS_REFERENCE}\n\n` : ''}## Forbidden constructs ${NODE_GROUPS_REFERENCE}
## Forbidden constructs
${renderForbiddenLines()} ${renderForbiddenLines()}
@@ -214,7 +209,6 @@ node access, or a step that would otherwise need three or more native nodes.
/** /**
* Full reference including groups docs. Materialized into Instance AI's * Full reference including groups docs. Materialized into Instance AI's
* knowledge base for on-demand reading; the MCP SDK reference embeds the * knowledge base for on-demand reading and embedded in the MCP SDK reference.
* groups-gated variant via `buildSdkLanguageReference` instead.
*/ */
export const SDK_LANGUAGE_REFERENCE = buildSdkLanguageReference(); export const SDK_LANGUAGE_REFERENCE = buildSdkLanguageReference();
@@ -7,10 +7,7 @@ import { mock } from 'vitest-mock-extended';
import { z } from 'zod'; import { z } from 'zod';
import { McpPostSaveMetricsService } from '../mcp-post-save-metrics.service'; import { McpPostSaveMetricsService } from '../mcp-post-save-metrics.service';
import { import { createCreateWorkflowFromCodeTool } from '../tools/workflow-builder/create-workflow-from-code.tool';
createCreateWorkflowFromCodeTool,
type CreateWorkflowFromCodeToolOptions,
} from '../tools/workflow-builder/create-workflow-from-code.tool';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -179,7 +176,7 @@ describe('create-workflow-from-code MCP tool', () => {
incrementPostSaveFailure: vi.fn(), incrementPostSaveFailure: vi.fn(),
}); });
const createTool = (options?: CreateWorkflowFromCodeToolOptions) => const createTool = () =>
createCreateWorkflowFromCodeTool( createCreateWorkflowFromCodeTool(
user, user,
workflowCreationService, workflowCreationService,
@@ -191,7 +188,6 @@ describe('create-workflow-from-code MCP tool', () => {
projectRepository, projectRepository,
dataTableOps as never, dataTableOps as never,
aiGatewayService, aiGatewayService,
options,
logger, logger,
postSaveMetrics, postSaveMetrics,
); );
@@ -1123,7 +1119,7 @@ describe('create-workflow-from-code MCP tool', () => {
}); });
}); });
describe('canvas groups (102_mcp_canvas_groups)', () => { describe('canvas groups', () => {
const nodeGroups = [{ id: 'g1', name: 'Ingestion', nodeIds: ['node-1', 'node-2'] }]; const nodeGroups = [{ id: 'g1', name: 'Ingestion', nodeIds: ['node-1', 'node-2'] }];
/** results.data of the last tracked telemetry event */ /** results.data of the last tracked telemetry event */
@@ -1134,22 +1130,7 @@ describe('create-workflow-from-code MCP tool', () => {
return payload.results?.data; return payload.results?.data;
}; };
test('flag off: groups from the code are dropped and telemetry is unchanged', async () => { test('groups from the code are persisted on the created workflow', async () => {
mockParseAndValidate.mockResolvedValue({
workflow: { ...mockWorkflowJson, nodeGroups },
warnings: [],
});
const result = await callHandler({ code: 'const wf = ...' });
expect(parseResult(result).workflowId).toBe('wf-saved-1');
const passedWorkflow = createWorkflowMock.mock.calls[0][1] as WorkflowEntity;
expect(passedWorkflow).not.toHaveProperty('nodeGroups');
// Telemetry payload is byte-identical to the pre-flag shape.
expect(trackedData()).toEqual({ workflowId: 'wf-saved-1', nodeCount: 2 });
});
test('flag on: groups from the code are persisted on the created workflow', async () => {
mockParseAndValidate.mockResolvedValue({ mockParseAndValidate.mockResolvedValue({
workflow: { workflow: {
...mockWorkflowJson, ...mockWorkflowJson,
@@ -1161,10 +1142,7 @@ describe('create-workflow-from-code MCP tool', () => {
warnings: [], warnings: [],
}); });
const result = await callHandler( const result = await callHandler({ code: 'const wf = ...' }, createTool());
{ code: 'const wf = ...' },
createTool({ canvasGroupsEnabled: true }),
);
expect(parseResult(result).workflowId).toBe('wf-saved-1'); expect(parseResult(result).workflowId).toBe('wf-saved-1');
const passedWorkflow = createWorkflowMock.mock.calls[0][1] as WorkflowEntity; const passedWorkflow = createWorkflowMock.mock.calls[0][1] as WorkflowEntity;
@@ -1172,10 +1150,10 @@ describe('create-workflow-from-code MCP tool', () => {
expect(trackedData()).toEqual({ workflowId: 'wf-saved-1', nodeCount: 2, groupCount: 1 }); expect(trackedData()).toEqual({ workflowId: 'wf-saved-1', nodeCount: 2, groupCount: 1 });
}); });
test('flag on: code without groups persists an empty group list', async () => { test('code without groups persists an empty group list', async () => {
mockParseAndValidate.mockResolvedValue({ workflow: mockWorkflowJson, warnings: [] }); mockParseAndValidate.mockResolvedValue({ workflow: mockWorkflowJson, warnings: [] });
await callHandler({ code: 'const wf = ...' }, createTool({ canvasGroupsEnabled: true })); await callHandler({ code: 'const wf = ...' }, createTool());
const passedWorkflow = createWorkflowMock.mock.calls[0][1] as WorkflowEntity; const passedWorkflow = createWorkflowMock.mock.calls[0][1] as WorkflowEntity;
expect(passedWorkflow.nodeGroups).toEqual([]); expect(passedWorkflow.nodeGroups).toEqual([]);
@@ -1183,11 +1161,8 @@ describe('create-workflow-from-code MCP tool', () => {
}); });
// Structural group rules (no triggers, single connected subgraph, no non-main connection // Structural group rules (no triggers, single connected subgraph, no non-main connection
// crossing the group boundary) aren't checked before `workflowCreationService.createWorkflow` // crossing the group boundary) are checked before `workflowCreationService.createWorkflow`,
// yet — that service's own `validateWorkflowNodeGroups` (a shared safety net used by the public // so an invalid group is dropped and reported in `skippedGroups` while the rest of the
// API, editor controller, importer, etc.) still rejects the whole creation for an invalid group.
// These tests describe the target behavior for that follow-up: with canvasGroupsEnabled on, an
// invalid group should be dropped and reported in `skippedGroups` instead, while the rest of the
// workflow is still created. // workflow is still created.
describe('structural validation', () => { describe('structural validation', () => {
beforeEach(() => { beforeEach(() => {
@@ -1214,10 +1189,7 @@ describe('create-workflow-from-code MCP tool', () => {
warnings: [], warnings: [],
}); });
const result = await callHandler( const result = await callHandler({ code: 'const wf = ...' }, createTool());
{ code: 'const wf = ...' },
createTool({ canvasGroupsEnabled: true }),
);
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
expect(parseResult(result).workflowId).toBe('wf-saved-1'); expect(parseResult(result).workflowId).toBe('wf-saved-1');
@@ -1269,10 +1241,7 @@ describe('create-workflow-from-code MCP tool', () => {
warnings: [], warnings: [],
}); });
const result = await callHandler( const result = await callHandler({ code: 'const wf = ...' }, createTool());
{ code: 'const wf = ...' },
createTool({ canvasGroupsEnabled: true }),
);
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
@@ -1319,10 +1288,7 @@ describe('create-workflow-from-code MCP tool', () => {
warnings: [], warnings: [],
}); });
const result = await callHandler( const result = await callHandler({ code: 'const wf = ...' }, createTool());
{ code: 'const wf = ...' },
createTool({ canvasGroupsEnabled: true }),
);
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
@@ -1380,10 +1346,7 @@ describe('create-workflow-from-code MCP tool', () => {
warnings: [], warnings: [],
}); });
const result = await callHandler( const result = await callHandler({ code: 'const wf = ...' }, createTool());
{ code: 'const wf = ...' },
createTool({ canvasGroupsEnabled: true }),
);
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
@@ -1408,10 +1371,7 @@ describe('create-workflow-from-code MCP tool', () => {
warnings: [], warnings: [],
}); });
const result = await callHandler( const result = await callHandler({ code: 'const wf = ...' }, createTool());
{ code: 'const wf = ...' },
createTool({ canvasGroupsEnabled: true }),
);
expect(result.isError).toBeUndefined(); expect(result.isError).toBeUndefined();
@@ -1430,10 +1390,7 @@ describe('create-workflow-from-code MCP tool', () => {
warnings: [], warnings: [],
}); });
const result = await callHandler( const result = await callHandler({ code: 'const wf = ...' }, createTool());
{ code: 'const wf = ...' },
createTool({ canvasGroupsEnabled: true }),
);
const response = parseResult(result); const response = parseResult(result);
expect(response.skippedGroups).toEqual([ expect(response.skippedGroups).toEqual([
@@ -1455,24 +1412,7 @@ describe('create-workflow-from-code MCP tool', () => {
parameters: {}, parameters: {},
})); }));
test('flag on: a saved canvas over the ceiling with no groups gets a warning', async () => { test('a saved canvas over the ceiling with no groups gets a warning', async () => {
mockParseAndValidate.mockResolvedValue({
workflow: { ...mockWorkflowJson, nodes: wideNodes },
warnings: [],
});
const result = await callHandler(
{ code: 'const wf = ...' },
createTool({ canvasGroupsEnabled: true }),
);
const response = parseResult(result);
expect(response.warnings).toEqual([
expect.objectContaining({ code: 'TOP_LEVEL_ITEMS_OVER_CEILING' }),
]);
});
test('flag off: no ceiling warning, groups cannot be kept anyway', async () => {
mockParseAndValidate.mockResolvedValue({ mockParseAndValidate.mockResolvedValue({
workflow: { ...mockWorkflowJson, nodes: wideNodes }, workflow: { ...mockWorkflowJson, nodes: wideNodes },
warnings: [], warnings: [],
@@ -1480,7 +1420,10 @@ describe('create-workflow-from-code MCP tool', () => {
const result = await callHandler({ code: 'const wf = ...' }, createTool()); const result = await callHandler({ code: 'const wf = ...' }, createTool());
expect(parseResult(result)).not.toHaveProperty('warnings'); const response = parseResult(result);
expect(response.warnings).toEqual([
expect.objectContaining({ code: 'TOP_LEVEL_ITEMS_OVER_CEILING' }),
]);
}); });
}); });
}); });
@@ -32,8 +32,7 @@ describe('get-workflow-best-practices MCP tool', () => {
telemetry = mock<Telemetry>(); telemetry = mock<Telemetry>();
}); });
const createTool = (canvasGroupsEnabled = false) => const createTool = () => createGetWorkflowBestPracticesTool(user, telemetry);
createGetWorkflowBestPracticesTool(user, telemetry, { canvasGroupsEnabled });
const textOf = (result: { content: Array<{ type: string; text?: string }> }) => const textOf = (result: { content: Array<{ type: string; text?: string }> }) =>
result.content.map((c) => c.text ?? '').join('\n'); result.content.map((c) => c.text ?? '').join('\n');
@@ -140,30 +139,13 @@ describe('get-workflow-best-practices MCP tool', () => {
}); });
describe('node grouping guidance', () => { describe('node grouping guidance', () => {
const listText = async (canvasGroupsEnabled: boolean) => { test('appends a grouping guidance section to the technique list', async () => {
const result = await createTool(canvasGroupsEnabled).handler( const result = await createTool().handler({ technique: 'list' }, {} as never);
{ technique: 'list' }, const text = textOf(result);
{} as never,
);
return textOf(result);
};
describe('when canvasGroupsEnabled is true', () => { expect(text).toContain('## Grouping');
test('appends a grouping guidance section to the technique list', async () => { expect(text).toMatch(/sub-workflow/i); // groups vs sub-workflows
const text = await listText(true); expect(text).toMatch(/collapsed/i); // created collapsed by default
expect(text).toContain('## Grouping');
expect(text).toMatch(/sub-workflow/i); // groups vs sub-workflows
expect(text).toMatch(/collapsed/i); // created collapsed by default
});
});
describe('when canvasGroupsEnabled is false', () => {
test('does not mention grouping in the technique list', async () => {
const text = await listText(false);
expect(text).not.toContain('## Grouping');
});
}); });
}); });
}); });
@@ -66,17 +66,13 @@ describe('get-workflow-sdk-reference MCP tool', () => {
expect(content).toContain('## Where to put runtime logic'); expect(content).toContain('## Where to put runtime logic');
}); });
test('embeds the groups docs exactly once when the flag is on, never when off', () => { test('embeds the groups docs exactly once', () => {
const withGroups = getSdkReferenceContent(undefined, { includeGroups: true }); expect(getSdkReferenceContent().split('## Node groups')).toHaveLength(2);
expect(withGroups.split('## Node groups')).toHaveLength(2);
const withoutGroups = getSdkReferenceContent(undefined, { includeGroups: false });
expect(withoutGroups).not.toContain('## Node groups');
}); });
}); });
test('accepts patterns_detailed as a tool section', async () => { test('accepts patterns_detailed as a tool section', async () => {
const tool = createGetWorkflowSdkReferenceTool(user, telemetry, { canvasGroupsEnabled: false }); const tool = createGetWorkflowSdkReferenceTool(user, telemetry);
const sectionSchema = tool.config.inputSchema?.section; const sectionSchema = tool.config.inputSchema?.section;
expect(tool.config.description).toContain('Required reference'); expect(tool.config.description).toContain('Required reference');
@@ -93,75 +89,34 @@ describe('get-workflow-sdk-reference MCP tool', () => {
}); });
}); });
describe('node groups (canvasGroupsEnabled)', () => { describe('node groups', () => {
describe('getSdkReferenceContent', () => { describe('getSdkReferenceContent', () => {
test('embeds verbatim the exact shared contents with the IAI in the full reference when enabled', () => { test('embeds verbatim the exact shared contents with the IAI in the full reference', () => {
expect(getSdkReferenceContent(undefined, { includeGroups: true })).toContain( expect(getSdkReferenceContent()).toContain(NODE_GROUPS_REFERENCE);
NODE_GROUPS_REFERENCE,
);
}); });
test('returns only the group section for section="groups" when enabled', () => { test('returns only the group section for section="groups"', () => {
const content = getSdkReferenceContent('groups', { includeGroups: true }); const content = getSdkReferenceContent('groups');
expect(content).toContain(NODE_GROUPS_REFERENCE); expect(content).toContain(NODE_GROUPS_REFERENCE);
// Just the group section — not the rest of the reference. // Just the group section — not the rest of the reference.
expect(content).not.toContain('## Workflow Patterns'); expect(content).not.toContain('## Workflow Patterns');
expect(content).not.toContain('<zero_item_safety>'); expect(content).not.toContain('<zero_item_safety>');
}); });
test('omits the groups description when disabled, leaving today’s output unchanged', () => {
const withFlagOff = getSdkReferenceContent(undefined, { includeGroups: false });
expect(withFlagOff).not.toContain(NODE_GROUPS_REFERENCE);
expect(withFlagOff).toBe(getSdkReferenceContent());
});
test('section="groups" yields no group content when disabled', () => {
expect(getSdkReferenceContent('groups', { includeGroups: false })).not.toContain(
NODE_GROUPS_REFERENCE,
);
});
}); });
describe('createGetWorkflowSdkReferenceTool', () => { describe('createGetWorkflowSdkReferenceTool', () => {
describe('When canvasGroupsEnabled is true', () => { test('the tool accepts section="groups"', () => {
test('the tool accepts section="groups"', () => { const tool = createGetWorkflowSdkReferenceTool(user, telemetry);
const enabled = createGetWorkflowSdkReferenceTool(user, telemetry, {
canvasGroupsEnabled: true,
});
expect(enabled.config.inputSchema?.section.safeParse('groups').success).toBe(true); expect(tool.config.inputSchema?.section.safeParse('groups').success).toBe(true);
});
test('the tool handler serves the groups reference', async () => {
const enabled = createGetWorkflowSdkReferenceTool(user, telemetry, {
canvasGroupsEnabled: true,
});
const enabledResult = await enabled.handler({ section: 'groups' }, {} as never);
expect(structuredOf(enabledResult)?.reference).toContain(NODE_GROUPS_REFERENCE);
});
}); });
describe('When canvasGroupsEnabled is false', () => { test('the tool handler serves the groups reference', async () => {
test('the tool does not accept section="groups"', () => { const tool = createGetWorkflowSdkReferenceTool(user, telemetry);
const disabled = createGetWorkflowSdkReferenceTool(user, telemetry, {
canvasGroupsEnabled: false,
});
expect(disabled.config.inputSchema?.section.safeParse('groups').success).toBe(false); const result = await tool.handler({ section: 'groups' }, {} as never);
}); expect(structuredOf(result)?.reference).toContain(NODE_GROUPS_REFERENCE);
test('the tool handler does not serve the groups reference', async () => {
const disabled = createGetWorkflowSdkReferenceTool(user, telemetry, {
canvasGroupsEnabled: false,
});
const disabledResult = await disabled.handler({ section: undefined }, {} as never);
expect(structuredOf(disabledResult)?.reference).not.toContain(NODE_GROUPS_REFERENCE);
});
}); });
}); });
}); });
@@ -35,52 +35,25 @@ describe('getMcpInstructions', () => {
}); });
describe('node groups pointer', () => { describe('node groups pointer', () => {
describe('when canvasGroupsEnabled is true', () => { test('points the client to the groups reference', () => {
test('points the client to the groups reference', () => { const instructions = getMcpInstructions({
const instructions = getMcpInstructions({ isBuilderEnabled: true,
isBuilderEnabled: true, isN8nConnectAvailable: true,
isN8nConnectAvailable: true,
canvasGroupsEnabled: true,
});
expect(instructions).toMatch(/group/i);
// Points at the on-demand groups section of the SDK reference.
expect(instructions).toContain('"groups"');
}); });
test('stays intro-only when the builder is disabled', () => { expect(instructions).toMatch(/group/i);
const instructions = getMcpInstructions({ // Points at the on-demand groups section of the SDK reference.
isBuilderEnabled: false, expect(instructions).toContain('"groups"');
isN8nConnectAvailable: false,
canvasGroupsEnabled: true,
});
expect(instructions).toContain('official MCP server for n8n');
expect(instructions).not.toContain('"groups"');
});
}); });
describe('when canvasGroupsEnabled is false', () => { test('stays intro-only when the builder is disabled', () => {
test('does not mention the groups reference', () => { const instructions = getMcpInstructions({
const instructions = getMcpInstructions({ isBuilderEnabled: false,
isBuilderEnabled: true, isN8nConnectAvailable: false,
isN8nConnectAvailable: true,
canvasGroupsEnabled: false,
});
expect(instructions).not.toContain('"groups"');
expect(instructions).not.toContain('does not fail the whole update');
expect(instructions).not.toContain('skippedOperations');
}); });
test('omits the groups pointer by default', () => { expect(instructions).toContain('official MCP server for n8n');
const instructions = getMcpInstructions({ expect(instructions).not.toContain('"groups"');
isBuilderEnabled: true,
isN8nConnectAvailable: true,
});
expect(instructions).not.toContain('"groups"');
});
}); });
}); });
@@ -132,7 +105,6 @@ describe('getMcpInstructions', () => {
const instructions = getMcpInstructions({ const instructions = getMcpInstructions({
isBuilderEnabled: true, isBuilderEnabled: true,
isN8nConnectAvailable: true, isN8nConnectAvailable: true,
canvasGroupsEnabled: true,
isAgentsEnabled: true, isAgentsEnabled: true,
isUserPreferencesEnabled: true, isUserPreferencesEnabled: true,
}); });
@@ -61,7 +61,6 @@ const ALL_MAPPED_TOOLS = new Set(Object.values(TOOLS_BY_SCOPE).flat());
const mcpFeatureFlags = (overrides: Partial<McpFeatureFlags> = {}): McpFeatureFlags => ({ const mcpFeatureFlags = (overrides: Partial<McpFeatureFlags> = {}): McpFeatureFlags => ({
mcpApps: { enabled: false, variant: 'unassigned' }, mcpApps: { enabled: false, variant: 'unassigned' },
canvasGroupsEnabled: false,
instanceContextEnabled: false, instanceContextEnabled: false,
// On by default so the drift guards below cover `get_user_preferences`. Its own // On by default so the drift guards below cover `get_user_preferences`. Its own
// registration tests set it explicitly either way. // registration tests set it explicitly either way.
@@ -118,7 +118,6 @@ describe('McpController', () => {
// when a flag matters. // when a flag matters.
(mcpService.resolveFeatureFlags as Mock).mockResolvedValue({ (mcpService.resolveFeatureFlags as Mock).mockResolvedValue({
mcpApps: { enabled: false, variant: 'unassigned' }, mcpApps: { enabled: false, variant: 'unassigned' },
canvasGroupsEnabled: false,
}); });
Container.set(Logger, logger); Container.set(Logger, logger);
@@ -171,7 +170,6 @@ describe('McpController', () => {
}); });
(mcpService.resolveFeatureFlags as Mock).mockResolvedValue({ (mcpService.resolveFeatureFlags as Mock).mockResolvedValue({
mcpApps: { enabled: true, variant: 'variant' }, mcpApps: { enabled: true, variant: 'variant' },
canvasGroupsEnabled: true,
}); });
const res = createRes(); const res = createRes();
@@ -195,7 +193,6 @@ describe('McpController', () => {
mcp_connection_status: 'success', mcp_connection_status: 'success',
mcp_apps_enabled: true, mcp_apps_enabled: true,
mcp_apps_variant: 'variant', mcp_apps_variant: 'variant',
mcp_canvas_groups_enabled: true,
}); });
}); });
@@ -207,7 +204,6 @@ describe('McpController', () => {
}); });
(mcpService.resolveFeatureFlags as Mock).mockResolvedValue({ (mcpService.resolveFeatureFlags as Mock).mockResolvedValue({
mcpApps: { enabled: false, variant: 'unassigned' }, mcpApps: { enabled: false, variant: 'unassigned' },
canvasGroupsEnabled: false,
}); });
const res = createRes(); const res = createRes();
@@ -240,7 +236,6 @@ describe('McpController', () => {
mcp_connection_status: 'success', mcp_connection_status: 'success',
mcp_apps_enabled: false, mcp_apps_enabled: false,
mcp_apps_variant: 'unassigned', mcp_apps_variant: 'unassigned',
mcp_canvas_groups_enabled: false,
}); });
}); });
@@ -357,7 +352,6 @@ describe('McpController', () => {
}); });
(mcpService.resolveFeatureFlags as Mock).mockResolvedValue({ (mcpService.resolveFeatureFlags as Mock).mockResolvedValue({
mcpApps: { enabled: true, variant: 'env_override' }, mcpApps: { enabled: true, variant: 'env_override' },
canvasGroupsEnabled: false,
}); });
const res = createRes(); const res = createRes();
@@ -389,7 +383,6 @@ describe('McpController', () => {
}); });
(mcpService.resolveFeatureFlags as Mock).mockResolvedValue({ (mcpService.resolveFeatureFlags as Mock).mockResolvedValue({
mcpApps: { enabled: true, variant: 'variant' }, mcpApps: { enabled: true, variant: 'variant' },
canvasGroupsEnabled: false,
}); });
const res = createRes(); const res = createRes();
@@ -407,7 +400,7 @@ describe('McpController', () => {
expect(mcpService.resolveFeatureFlags as Mock).toHaveBeenCalledTimes(1); expect(mcpService.resolveFeatureFlags as Mock).toHaveBeenCalledTimes(1);
expect(mcpService.getServer as unknown as Mock).toHaveBeenCalledWith( expect(mcpService.getServer as unknown as Mock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-1' }), expect.objectContaining({ id: 'user-1' }),
{ mcpApps: { enabled: true, variant: 'variant' }, canvasGroupsEnabled: false }, { mcpApps: { enabled: true, variant: 'variant' } },
{ name: 'Claude', version: '1.0.0' }, { name: 'Claude', version: '1.0.0' },
{ caller: undefined, grantedScopes: undefined }, { caller: undefined, grantedScopes: undefined },
); );
@@ -424,7 +417,6 @@ describe('McpController', () => {
}); });
(mcpService.resolveFeatureFlags as Mock).mockResolvedValue({ (mcpService.resolveFeatureFlags as Mock).mockResolvedValue({
mcpApps: { enabled: false, variant: 'unassigned' }, mcpApps: { enabled: false, variant: 'unassigned' },
canvasGroupsEnabled: false,
aiPreferencesEnabled: true, aiPreferencesEnabled: true,
}); });
const res = createRes(); const res = createRes();
@@ -462,7 +454,6 @@ describe('McpController', () => {
}); });
(mcpService.resolveFeatureFlags as Mock).mockResolvedValue({ (mcpService.resolveFeatureFlags as Mock).mockResolvedValue({
mcpApps: { enabled: false, variant: 'control' }, mcpApps: { enabled: false, variant: 'control' },
canvasGroupsEnabled: false,
}); });
const res = createRes(); const res = createRes();
@@ -481,7 +472,7 @@ describe('McpController', () => {
expect(mcpService.resolveFeatureFlags as Mock).toHaveBeenCalledTimes(1); expect(mcpService.resolveFeatureFlags as Mock).toHaveBeenCalledTimes(1);
expect(mcpService.getServer as unknown as Mock).toHaveBeenCalledWith( expect(mcpService.getServer as unknown as Mock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-1' }), expect.objectContaining({ id: 'user-1' }),
{ mcpApps: { enabled: false, variant: 'control' }, canvasGroupsEnabled: false }, { mcpApps: { enabled: false, variant: 'control' } },
undefined, undefined,
{ caller: undefined, grantedScopes: undefined }, { caller: undefined, grantedScopes: undefined },
); );
@@ -3,7 +3,6 @@ import {
MCP_APPS_FLAG, MCP_APPS_FLAG,
MCP_APPS_VARIANT_CONTROL, MCP_APPS_VARIANT_CONTROL,
MCP_APPS_VARIANT_ENABLED, MCP_APPS_VARIANT_ENABLED,
MCP_CANVAS_GROUPS_FLAG,
CONTEXT_PREFERENCES_CONTROL_VARIANT, CONTEXT_PREFERENCES_CONTROL_VARIANT,
CONTEXT_PREFERENCES_ENABLED_VARIANT, CONTEXT_PREFERENCES_ENABLED_VARIANT,
CONTEXT_PREFERENCES_FLAG, CONTEXT_PREFERENCES_FLAG,
@@ -74,7 +73,6 @@ const mockAiGatewayService = () =>
const mcpFeatureFlags = (overrides: Partial<McpFeatureFlags> = {}): McpFeatureFlags => ({ const mcpFeatureFlags = (overrides: Partial<McpFeatureFlags> = {}): McpFeatureFlags => ({
mcpApps: { enabled: false, variant: 'unassigned' }, mcpApps: { enabled: false, variant: 'unassigned' },
canvasGroupsEnabled: false,
instanceContextEnabled: false, instanceContextEnabled: false,
aiPreferencesEnabled: false, aiPreferencesEnabled: false,
...overrides, ...overrides,
@@ -360,7 +358,6 @@ describe('McpService', () => {
const buildResolutionService = (opts: { const buildResolutionService = (opts: {
postHogClient: Mocked<PostHogClient>; postHogClient: Mocked<PostHogClient>;
mcpAppsEnabled?: boolean; mcpAppsEnabled?: boolean;
mcpCanvasGroupsEnabled?: boolean;
mcpInstanceContextEnabled?: boolean; mcpInstanceContextEnabled?: boolean;
}) => }) =>
new McpService( new McpService(
@@ -377,7 +374,6 @@ describe('McpService', () => {
webhook: '/webhook', webhook: '/webhook',
webhookTest: '/webhook-test', webhookTest: '/webhook-test',
mcpAppsEnabled: opts.mcpAppsEnabled ?? false, mcpAppsEnabled: opts.mcpAppsEnabled ?? false,
mcpCanvasGroupsEnabled: opts.mcpCanvasGroupsEnabled ?? false,
mcpInstanceContextEnabled: opts.mcpInstanceContextEnabled ?? false, mcpInstanceContextEnabled: opts.mcpInstanceContextEnabled ?? false,
}, },
}), }),
@@ -418,13 +414,11 @@ describe('McpService', () => {
const postHogClient = mockInstance(PostHogClient); const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({ postHogClient.getFeatureFlags.mockResolvedValue({
[MCP_APPS_FLAG]: MCP_APPS_VARIANT_ENABLED, [MCP_APPS_FLAG]: MCP_APPS_VARIANT_ENABLED,
[MCP_CANVAS_GROUPS_FLAG]: true,
}); });
const service = buildResolutionService({ postHogClient }); const service = buildResolutionService({ postHogClient });
await expect(service.resolveFeatureFlags(user)).resolves.toEqual({ await expect(service.resolveFeatureFlags(user)).resolves.toEqual({
mcpApps: { enabled: true, variant: 'variant' }, mcpApps: { enabled: true, variant: 'variant' },
canvasGroupsEnabled: true,
instanceContextEnabled: false, instanceContextEnabled: false,
aiPreferencesEnabled: false, aiPreferencesEnabled: false,
}); });
@@ -550,56 +544,13 @@ describe('McpService', () => {
}); });
}); });
describe('canvas groups', () => {
it('enables canvas groups for users with the boolean flag set', async () => {
const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({ [MCP_CANVAS_GROUPS_FLAG]: true });
const service = buildResolutionService({ postHogClient });
await expect(service.resolveFeatureFlags(user)).resolves.toMatchObject({
canvasGroupsEnabled: true,
});
});
it('keeps canvas groups disabled when the flag is missing', async () => {
const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({});
const service = buildResolutionService({ postHogClient });
await expect(service.resolveFeatureFlags(user)).resolves.toMatchObject({
canvasGroupsEnabled: false,
});
});
it('treats non-boolean flag values as disabled', async () => {
const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({ [MCP_CANVAS_GROUPS_FLAG]: 'variant' });
const service = buildResolutionService({ postHogClient });
await expect(service.resolveFeatureFlags(user)).resolves.toMatchObject({
canvasGroupsEnabled: false,
});
});
it('enables canvas groups when the operator force-enables them', async () => {
const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({});
const service = buildResolutionService({ postHogClient, mcpCanvasGroupsEnabled: true });
await expect(service.resolveFeatureFlags(user)).resolves.toMatchObject({
canvasGroupsEnabled: true,
});
});
});
it('still queries PostHog when only some features are env-overridden', async () => { it('still queries PostHog when only some features are env-overridden', async () => {
const postHogClient = mockInstance(PostHogClient); const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({ [MCP_CANVAS_GROUPS_FLAG]: true }); postHogClient.getFeatureFlags.mockResolvedValue({});
const service = buildResolutionService({ postHogClient, mcpAppsEnabled: true }); const service = buildResolutionService({ postHogClient, mcpAppsEnabled: true });
await expect(service.resolveFeatureFlags(user)).resolves.toEqual({ await expect(service.resolveFeatureFlags(user)).resolves.toEqual({
mcpApps: { enabled: true, variant: 'env_override' }, mcpApps: { enabled: true, variant: 'env_override' },
canvasGroupsEnabled: true,
instanceContextEnabled: false, instanceContextEnabled: false,
aiPreferencesEnabled: false, aiPreferencesEnabled: false,
}); });
@@ -615,13 +566,11 @@ describe('McpService', () => {
const service = buildResolutionService({ const service = buildResolutionService({
postHogClient, postHogClient,
mcpAppsEnabled: true, mcpAppsEnabled: true,
mcpCanvasGroupsEnabled: true,
mcpInstanceContextEnabled: true, mcpInstanceContextEnabled: true,
}); });
await expect(service.resolveFeatureFlags(user)).resolves.toEqual({ await expect(service.resolveFeatureFlags(user)).resolves.toEqual({
mcpApps: { enabled: true, variant: 'env_override' }, mcpApps: { enabled: true, variant: 'env_override' },
canvasGroupsEnabled: true,
instanceContextEnabled: true, instanceContextEnabled: true,
aiPreferencesEnabled: true, aiPreferencesEnabled: true,
}); });
File diff suppressed because it is too large Load Diff
@@ -5,10 +5,7 @@ import { NodeConnectionTypes } from 'n8n-workflow';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { import { createValidateWorkflowCodeTool } from '../tools/workflow-builder/validate-workflow-code.tool';
createValidateWorkflowCodeTool,
type ValidateWorkflowCodeToolOptions,
} from '../tools/workflow-builder/validate-workflow-code.tool';
// Mocks referenced inside vi.mock factories must come from vi.hoisted. // Mocks referenced inside vi.mock factories must come from vi.hoisted.
const { mockParseAndValidate, mockStripImportStatements } = vi.hoisted(() => ({ const { mockParseAndValidate, mockStripImportStatements } = vi.hoisted(() => ({
@@ -64,8 +61,7 @@ describe('validate-workflow-code MCP tool', () => {
}) as typeof nodeTypes.getByNameAndVersion); }) as typeof nodeTypes.getByNameAndVersion);
}); });
const createTool = (options?: ValidateWorkflowCodeToolOptions) => const createTool = () => createValidateWorkflowCodeTool(user, telemetry, nodeTypes);
createValidateWorkflowCodeTool(user, telemetry, nodeTypes, options);
describe('smoke tests', () => { describe('smoke tests', () => {
test('creates tool with correct name and readOnlyHint=true', () => { test('creates tool with correct name and readOnlyHint=true', () => {
@@ -274,7 +270,7 @@ describe('validate-workflow-code MCP tool', () => {
}); });
}); });
describe('canvas groups (102_mcp_canvas_groups)', () => { describe('canvas groups', () => {
const makeGroupedWorkflow = ( const makeGroupedWorkflow = (
nodeGroups: Array<{ id: string; name: string; nodeIds: string[] }>, nodeGroups: Array<{ id: string; name: string; nodeIds: string[] }>,
) => ({ ) => ({
@@ -333,29 +329,13 @@ describe('validate-workflow-code MCP tool', () => {
}) as typeof nodeTypes.getByNameAndVersion); }) as typeof nodeTypes.getByNameAndVersion);
}); });
test('flag off: groups are not validated and output/telemetry are unchanged', async () => { test('a valid group produces no errors and is counted in telemetry', async () => {
mockParseAndValidate.mockResolvedValue({
workflow: makeGroupedWorkflow([{ id: 'g1', name: 'Group', nodeIds: ['trigger', 'a'] }]),
warnings: [],
});
const tool = createTool();
const result = await tool.handler({ code: 'const wf = ...' }, {} as never);
const response = parseResult(result);
expect(response.valid).toBe(true);
expect(response).not.toHaveProperty('warnings');
// Telemetry payload is byte-identical to the pre-flag shape.
expect(trackedData()).toEqual({ nodeCount: 3, warningCount: 0 });
});
test('flag on: a valid group produces no errors and is counted in telemetry', async () => {
mockParseAndValidate.mockResolvedValue({ mockParseAndValidate.mockResolvedValue({
workflow: makeGroupedWorkflow([{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }]), workflow: makeGroupedWorkflow([{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }]),
warnings: [], warnings: [],
}); });
const tool = createTool({ canvasGroupsEnabled: true }); const tool = createTool();
const result = await tool.handler({ code: 'const wf = ...' }, {} as never); const result = await tool.handler({ code: 'const wf = ...' }, {} as never);
const response = parseResult(result); const response = parseResult(result);
@@ -368,13 +348,13 @@ describe('validate-workflow-code MCP tool', () => {
}); });
}); });
test('flag on: group violations fail validation with the save-path message', async () => { test('group violations fail validation with the save-path message', async () => {
mockParseAndValidate.mockResolvedValue({ mockParseAndValidate.mockResolvedValue({
workflow: makeGroupedWorkflow([{ id: 'g1', name: 'Group', nodeIds: ['trigger', 'a'] }]), workflow: makeGroupedWorkflow([{ id: 'g1', name: 'Group', nodeIds: ['trigger', 'a'] }]),
warnings: [], warnings: [],
}); });
const tool = createTool({ canvasGroupsEnabled: true }); const tool = createTool();
const result = await tool.handler({ code: 'const wf = ...' }, {} as never); const result = await tool.handler({ code: 'const wf = ...' }, {} as never);
const response = parseResult(result); const response = parseResult(result);
@@ -394,7 +374,7 @@ describe('validate-workflow-code MCP tool', () => {
}); });
}); });
test('flag on: all group violations are reported as errors, one entry each', async () => { test('all group violations are reported as errors, one entry each', async () => {
const sdkWarning = { code: 'deprecated', message: 'Node X is deprecated' }; const sdkWarning = { code: 'deprecated', message: 'Node X is deprecated' };
mockParseAndValidate.mockResolvedValue({ mockParseAndValidate.mockResolvedValue({
workflow: makeGroupedWorkflow([ workflow: makeGroupedWorkflow([
@@ -404,7 +384,7 @@ describe('validate-workflow-code MCP tool', () => {
warnings: [sdkWarning], warnings: [sdkWarning],
}); });
const tool = createTool({ canvasGroupsEnabled: true }); const tool = createTool();
const result = await tool.handler({ code: 'const wf = ...' }, {} as never); const result = await tool.handler({ code: 'const wf = ...' }, {} as never);
const response = parseResult(result); const response = parseResult(result);
@@ -429,7 +409,7 @@ describe('validate-workflow-code MCP tool', () => {
}); });
}); });
test('flag on: connections under unsafe object keys are skipped, not assigned', async () => { test('connections under unsafe object keys are skipped, not assigned', async () => {
// Built via JSON.parse: an object literal with a "__proto__" key would // Built via JSON.parse: an object literal with a "__proto__" key would
// invoke the prototype setter instead of creating an own property. // invoke the prototype setter instead of creating an own property.
// Both entries would be boundary-crossing ai_languageModel connections // Both entries would be boundary-crossing ai_languageModel connections
@@ -448,7 +428,7 @@ describe('validate-workflow-code MCP tool', () => {
warnings: [], warnings: [],
}); });
const tool = createTool({ canvasGroupsEnabled: true }); const tool = createTool();
const result = await tool.handler({ code: 'const wf = ...' }, {} as never); const result = await tool.handler({ code: 'const wf = ...' }, {} as never);
const response = parseResult(result); const response = parseResult(result);
@@ -462,13 +442,13 @@ describe('validate-workflow-code MCP tool', () => {
}); });
}); });
test('flag on: workflows without groups report groupCount 0', async () => { test('workflows without groups report groupCount 0', async () => {
mockParseAndValidate.mockResolvedValue({ mockParseAndValidate.mockResolvedValue({
workflow: makeGroupedWorkflow([]), workflow: makeGroupedWorkflow([]),
warnings: [], warnings: [],
}); });
const tool = createTool({ canvasGroupsEnabled: true }); const tool = createTool();
const result = await tool.handler({ code: 'const wf = ...' }, {} as never); const result = await tool.handler({ code: 'const wf = ...' }, {} as never);
const response = parseResult(result); const response = parseResult(result);
@@ -1112,13 +1112,17 @@ describe('applyOperations', () => {
expect(result.workflow.nodeGroups).toEqual([]); expect(result.workflow.nodeGroups).toEqual([]);
}); });
test('fails when a node name does not exist', () => { test('is skipped when a node name does not exist', () => {
const result = applyOperations(baseWorkflow(), [ const result = applyOperations(baseWorkflow(), [
{ type: 'setNodeGroups', nodeGroups: [{ name: 'Group', nodeNames: ['A', 'Missing'] }] }, { type: 'setNodeGroups', nodeGroups: [{ name: 'Group', nodeNames: ['A', 'Missing'] }] },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain("node 'Missing' in group 'Group' not found"); return;
}
expect(result.skippedOperations[0]?.reason).toContain(
"node 'Missing' in group 'Group' not found",
);
}); });
test('resolves nodes added earlier in the same operation batch', () => { test('resolves nodes added earlier in the same operation batch', () => {
@@ -1236,7 +1240,7 @@ describe('applyOperations', () => {
]); ]);
}); });
test('fails when a group with the same name already exists', () => { test('is skipped when a group with the same name already exists', () => {
const wf = { const wf = {
...baseWorkflow(), ...baseWorkflow(),
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }], nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }],
@@ -1244,12 +1248,16 @@ describe('applyOperations', () => {
const result = applyOperations(wf, [ const result = applyOperations(wf, [
{ type: 'addNodeGroup', name: 'Group', nodeNames: ['B'] }, { type: 'addNodeGroup', name: 'Group', nodeNames: ['B'] },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain("a node group named 'Group' already exists"); return;
}
expect(result.skippedOperations[0]?.reason).toContain(
"a node group named 'Group' already exists",
);
}); });
test('fails when a group with the same id already exists', () => { test('is skipped when a group with the same id already exists', () => {
const wf = { const wf = {
...baseWorkflow(), ...baseWorkflow(),
nodeGroups: [{ id: 'g1', name: 'Existing', nodeIds: ['a'] }], nodeGroups: [{ id: 'g1', name: 'Existing', nodeIds: ['a'] }],
@@ -1257,18 +1265,26 @@ describe('applyOperations', () => {
const result = applyOperations(wf, [ const result = applyOperations(wf, [
{ type: 'addNodeGroup', id: 'g1', name: 'New', nodeNames: ['B'] }, { type: 'addNodeGroup', id: 'g1', name: 'New', nodeNames: ['B'] },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain("a node group with id 'g1' already exists"); return;
}
expect(result.skippedOperations[0]?.reason).toContain(
"a node group with id 'g1' already exists",
);
}); });
test('fails when a node name does not exist', () => { test('is skipped when a node name does not exist', () => {
const result = applyOperations(baseWorkflow(), [ const result = applyOperations(baseWorkflow(), [
{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] }, { type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain("node 'Missing' in group 'Group' not found"); return;
}
expect(result.skippedOperations[0]?.reason).toContain(
"node 'Missing' in group 'Group' not found",
);
}); });
test('dedupes duplicate node names', () => { test('dedupes duplicate node names', () => {
@@ -1339,13 +1355,15 @@ describe('applyOperations', () => {
expect(result.workflow.nodes).toHaveLength(2); expect(result.workflow.nodes).toHaveLength(2);
}); });
test('fails when the group does not exist', () => { test('is skipped when the group does not exist', () => {
const result = applyOperations(baseWorkflow(), [ const result = applyOperations(baseWorkflow(), [
{ type: 'removeNodeGroup', groupName: 'Missing' }, { type: 'removeNodeGroup', groupName: 'Missing' },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain("node group 'Missing' not found"); return;
}
expect(result.skippedOperations[0]?.reason).toContain("node group 'Missing' not found");
}); });
test('can remove a group added earlier in the same batch', () => { test('can remove a group added earlier in the same batch', () => {
@@ -1385,13 +1403,17 @@ describe('applyOperations', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
test('fails when the new name collides with another group', () => { test('is skipped when the new name collides with another group', () => {
const result = applyOperations(groupedWorkflow(), [ const result = applyOperations(groupedWorkflow(), [
{ type: 'updateNodeGroup', groupName: 'Group', newName: 'Other' }, { type: 'updateNodeGroup', groupName: 'Group', newName: 'Other' },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain("a node group named 'Other' already exists"); return;
}
expect(result.skippedOperations[0]?.reason).toContain(
"a node group named 'Other' already exists",
);
}); });
test('replaces membership resolving and deduping node names', () => { test('replaces membership resolving and deduping node names', () => {
@@ -1403,13 +1425,17 @@ describe('applyOperations', () => {
expect(result.workflow.nodeGroups![1].nodeIds).toEqual(['a']); expect(result.workflow.nodeGroups![1].nodeIds).toEqual(['a']);
}); });
test('fails when a member node name does not exist', () => { test('is skipped when a member node name does not exist', () => {
const result = applyOperations(groupedWorkflow(), [ const result = applyOperations(groupedWorkflow(), [
{ type: 'updateNodeGroup', groupName: 'Group', nodeNames: ['Missing'] }, { type: 'updateNodeGroup', groupName: 'Group', nodeNames: ['Missing'] },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain("node 'Missing' in group 'Group' not found"); return;
}
expect(result.skippedOperations[0]?.reason).toContain(
"node 'Missing' in group 'Group' not found",
);
}); });
test('sets a new description', () => { test('sets a new description', () => {
@@ -1439,22 +1465,26 @@ describe('applyOperations', () => {
expect(result.workflow.nodeGroups![0].description).toBe('Old description'); expect(result.workflow.nodeGroups![0].description).toBe('Old description');
}); });
test('fails when the group does not exist', () => { test('is skipped when the group does not exist', () => {
const result = applyOperations(baseWorkflow(), [ const result = applyOperations(baseWorkflow(), [
{ type: 'updateNodeGroup', groupName: 'Missing', newName: 'X' }, { type: 'updateNodeGroup', groupName: 'Missing', newName: 'X' },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain("node group 'Missing' not found"); return;
}
expect(result.skippedOperations[0]?.reason).toContain("node group 'Missing' not found");
}); });
test('fails when no change is specified', () => { test('is skipped when no change is specified', () => {
const result = applyOperations(groupedWorkflow(), [ const result = applyOperations(groupedWorkflow(), [
{ type: 'updateNodeGroup', groupName: 'Group' }, { type: 'updateNodeGroup', groupName: 'Group' },
]); ]);
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) return; if (!result.success) {
expect(result.error).toContain( return;
}
expect(result.skippedOperations[0]?.reason).toContain(
'updateNodeGroup must specify at least one of newName, nodeNames, or description', 'updateNodeGroup must specify at least one of newName, nodeNames, or description',
); );
}); });
@@ -1469,11 +1499,9 @@ describe('applyOperations', () => {
// Valid nodeNames combined with a colliding newName: since a failing group // Valid nodeNames combined with a colliding newName: since a failing group
// op is skipped rather than fatal, applying the membership before checking // op is skipped rather than fatal, applying the membership before checking
// the name would persist a change the skip report says never happened. // the name would persist a change the skip report says never happened.
const result = applyOperations( const result = applyOperations(groupedWorkflow(), [
groupedWorkflow(), { type: 'updateNodeGroup', groupName: 'Group', nodeNames: ['A', 'B'], newName: 'Other' },
[{ type: 'updateNodeGroup', groupName: 'Group', nodeNames: ['A', 'B'], newName: 'Other' }], ]);
{ canvasGroupsEnabled: true },
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (!result.success) return; if (!result.success) return;
expect(result.skippedOperations).toEqual([ expect(result.skippedOperations).toEqual([
@@ -1595,142 +1623,121 @@ describe('applyOperations', () => {
}); });
describe('non-fatal operation types', () => { describe('non-fatal operation types', () => {
describe('canvasGroupsEnabled off', () => { test('a normal batch with no failures succeeds with no skipped operations', () => {
test('a normal batch with no failures succeeds with no skipped operations', () => { const result = applyOperations(baseWorkflow(), [
const result = applyOperations(baseWorkflow(), [ { type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } }, ]);
]); expect(result.success).toBe(true);
expect(result.success).toBe(true); if (!result.success) {
if (!result.success) return; return;
expect(result.skippedOperations ?? []).toEqual([]); }
}); expect(result.skippedOperations ?? []).toEqual([]);
test('a failing group operation still aborts the whole batch', () => {
const result = applyOperations(baseWorkflow(), [
{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
]);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toContain("node 'Missing' in group 'Group' not found");
expect(result.opIndex).toBe(0);
});
test('a failing non-group operation still aborts the whole batch', () => {
const result = applyOperations(baseWorkflow(), [
{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
{ type: 'removeNode', nodeName: 'Missing' },
]);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.opIndex).toBe(1);
});
}); });
describe('canvasGroupsEnabled on', () => { test('a failing non-group operation still aborts the whole batch', () => {
test('a failing addNodeGroup is skipped while surrounding operations still apply', () => { const result = applyOperations(baseWorkflow(), [
const wf = baseWorkflow(); { type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
const ops: PartialUpdateOperation[] = [ { type: 'removeNode', nodeName: 'Missing' },
{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } }, ]);
{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] }, expect(result.success).toBe(false);
{ type: 'addConnection', source: 'A', target: 'C' }, if (result.success) {
]; return;
const result = applyOperations(wf, ops, { canvasGroupsEnabled: true }); }
expect(result.success).toBe(true); expect(result.opIndex).toBe(1);
if (!result.success) return; });
expect(result.workflow.nodes.some((n) => n.name === 'C')).toBe(true);
expect(result.workflow.connections.A?.main?.[0]).toEqual(
expect.arrayContaining([expect.objectContaining({ node: 'C' })]),
);
expect(result.workflow.nodeGroups ?? []).toEqual([]);
expect(result.skippedOperations).toEqual([
{
opIndex: 1,
type: 'addNodeGroup',
reason: "node 'Missing' in group 'Group' not found",
},
]);
});
test('a failing non-group operation still aborts the whole batch even with the flag on', () => { test('a failing addNodeGroup is skipped while surrounding operations still apply', () => {
const result = applyOperations( const wf = baseWorkflow();
baseWorkflow(), const ops: PartialUpdateOperation[] = [
[{ type: 'removeNode', nodeName: 'Missing' }], { type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } },
{ canvasGroupsEnabled: true }, { type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
); { type: 'addConnection', source: 'A', target: 'C' },
expect(result.success).toBe(false); ];
if (result.success) return; const result = applyOperations(wf, ops);
expect(result.opIndex).toBe(0); expect(result.success).toBe(true);
}); if (!result.success) return;
expect(result.workflow.nodes.some((n) => n.name === 'C')).toBe(true);
expect(result.workflow.connections.A?.main?.[0]).toEqual(
expect.arrayContaining([expect.objectContaining({ node: 'C' })]),
);
expect(result.workflow.nodeGroups ?? []).toEqual([]);
expect(result.skippedOperations).toEqual([
{
opIndex: 1,
type: 'addNodeGroup',
reason: "node 'Missing' in group 'Group' not found",
},
]);
});
test('two non-fatal failures in the same batch are both skipped, in order', () => { test('two non-fatal failures in the same batch are both skipped, in order', () => {
const wf = { const wf = {
...baseWorkflow(), ...baseWorkflow(),
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }], nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }],
}; };
const ops: PartialUpdateOperation[] = [ const ops: PartialUpdateOperation[] = [
{ type: 'addNodeGroup', name: 'Group', nodeNames: ['B'] }, { type: 'addNodeGroup', name: 'Group', nodeNames: ['B'] },
{ type: 'updateNodeGroup', groupName: 'Missing', newName: 'X' }, { type: 'updateNodeGroup', groupName: 'Missing', newName: 'X' },
]; ];
const result = applyOperations(wf, ops, { canvasGroupsEnabled: true }); const result = applyOperations(wf, ops);
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (!result.success) return; if (!result.success) return;
expect(result.workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a'] }]); expect(result.workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a'] }]);
expect(result.skippedOperations).toEqual([ expect(result.skippedOperations).toEqual([
{ {
opIndex: 0, opIndex: 0,
type: 'addNodeGroup', type: 'addNodeGroup',
reason: "a node group named 'Group' already exists", reason: "a node group named 'Group' already exists",
}, },
{ {
opIndex: 1, opIndex: 1,
type: 'updateNodeGroup', type: 'updateNodeGroup',
reason: "node group 'Missing' not found", reason: "node group 'Missing' not found",
}, },
]); ]);
}); });
test('a batch where every operation is a failing group op still succeeds with no groups persisted', () => { test('a batch where every operation is a failing group op still succeeds with no groups persisted', () => {
const ops: PartialUpdateOperation[] = [ const ops: PartialUpdateOperation[] = [
{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] }, { type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
{ type: 'removeNodeGroup', groupName: 'Missing' }, { type: 'removeNodeGroup', groupName: 'Missing' },
]; ];
const result = applyOperations(baseWorkflow(), ops, { canvasGroupsEnabled: true }); const result = applyOperations(baseWorkflow(), ops);
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (!result.success) return; if (!result.success) return;
expect(result.workflow.nodeGroups ?? []).toEqual([]); expect(result.workflow.nodeGroups ?? []).toEqual([]);
expect(result.nodeGroupsChanged).toBe(false); expect(result.nodeGroupsChanged).toBe(false);
expect(result.skippedOperations).toHaveLength(2); expect(result.skippedOperations).toHaveLength(2);
}); });
test('skipping a non-fatal operation does not corrupt state for later operations', () => { test('skipping a non-fatal operation does not corrupt state for later operations', () => {
const ops: PartialUpdateOperation[] = [ const ops: PartialUpdateOperation[] = [
{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } }, { type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } },
{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] }, { type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
{ type: 'setNodeParameter', nodeName: 'C', path: '/foo', value: 'bar' }, { type: 'setNodeParameter', nodeName: 'C', path: '/foo', value: 'bar' },
]; ];
const result = applyOperations(baseWorkflow(), ops, { canvasGroupsEnabled: true }); const result = applyOperations(baseWorkflow(), ops);
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (!result.success) return; if (!result.success) return;
expect(result.workflow.nodes.find((n) => n.name === 'C')!.parameters).toEqual({ expect(result.workflow.nodes.find((n) => n.name === 'C')!.parameters).toEqual({
foo: 'bar', foo: 'bar',
});
expect(result.skippedOperations).toHaveLength(1);
}); });
expect(result.skippedOperations).toHaveLength(1);
});
test('opIndex in skippedOperations reflects the original position in the input array', () => { test('opIndex in skippedOperations reflects the original position in the input array', () => {
const ops: PartialUpdateOperation[] = [ const ops: PartialUpdateOperation[] = [
{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } }, { type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } }, { type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } },
{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] }, { type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
{ type: 'setNodeParameter', nodeName: 'C', path: '/foo', value: 'bar' }, { type: 'setNodeParameter', nodeName: 'C', path: '/foo', value: 'bar' },
]; ];
const result = applyOperations(baseWorkflow(), ops, { canvasGroupsEnabled: true }); const result = applyOperations(baseWorkflow(), ops);
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (!result.success) return; if (!result.success) return;
expect(result.skippedOperations).toEqual([ expect(result.skippedOperations).toEqual([
{ opIndex: 2, type: 'addNodeGroup', reason: expect.any(String) as string }, { opIndex: 2, type: 'addNodeGroup', reason: expect.any(String) as string },
]); ]);
});
}); });
}); });
@@ -145,7 +145,6 @@ export class McpController {
auth_type: (req as McpAuthenticatedRequest).mcpCaller?.authType, auth_type: (req as McpAuthenticatedRequest).mcpCaller?.authType,
mcp_apps_enabled: featureFlags.mcpApps.enabled, mcp_apps_enabled: featureFlags.mcpApps.enabled,
mcp_apps_variant: featureFlags.mcpApps.variant, mcp_apps_variant: featureFlags.mcpApps.variant,
mcp_canvas_groups_enabled: featureFlags.canvasGroupsEnabled,
}; };
// In stateless mode, create a new instance of transport and server for each request // In stateless mode, create a new instance of transport and server for each request
+5 -21
View File
@@ -3,7 +3,6 @@ import {
MCP_APPS_FLAG, MCP_APPS_FLAG,
MCP_APPS_VARIANT_CONTROL, MCP_APPS_VARIANT_CONTROL,
MCP_APPS_VARIANT_ENABLED, MCP_APPS_VARIANT_ENABLED,
MCP_CANVAS_GROUPS_FLAG,
MCP_INSTANCE_CONTEXT_FLAG, MCP_INSTANCE_CONTEXT_FLAG,
CONTEXT_PREFERENCES_ENABLED_VARIANT, CONTEXT_PREFERENCES_ENABLED_VARIANT,
CONTEXT_PREFERENCES_FLAG, CONTEXT_PREFERENCES_FLAG,
@@ -137,8 +136,6 @@ export type McpAppsResolution = {
/** Per-user resolution of every PostHog-gated MCP feature. */ /** Per-user resolution of every PostHog-gated MCP feature. */
export type McpFeatureFlags = { export type McpFeatureFlags = {
mcpApps: McpAppsResolution; mcpApps: McpAppsResolution;
/** Canvas node-group support in the workflow-builder tools. */
canvasGroupsEnabled: boolean;
/** The instance-context read surface: the activity tools and node-usage. */ /** The instance-context read surface: the activity tools and node-usage. */
instanceContextEnabled: boolean; instanceContextEnabled: boolean;
/** The `get_user_preferences` tool. */ /** The `get_user_preferences` tool. */
@@ -253,8 +250,7 @@ export class McpService {
* PostHog. * PostHog.
*/ */
async resolveFeatureFlags(user: User): Promise<McpFeatureFlags> { async resolveFeatureFlags(user: User): Promise<McpFeatureFlags> {
const { mcpAppsEnabled, mcpCanvasGroupsEnabled, mcpInstanceContextEnabled } = const { mcpAppsEnabled, mcpInstanceContextEnabled } = this.globalConfig.endpoints;
this.globalConfig.endpoints;
// `PostHogClient.getFeatureFlags` swallows PostHog errors internally and // `PostHogClient.getFeatureFlags` swallows PostHog errors internally and
// returns `{}`, so a transient outage fails closed (feature off, MCP Apps // returns `{}`, so a transient outage fails closed (feature off, MCP Apps
@@ -263,7 +259,6 @@ export class McpService {
return { return {
mcpApps: this.resolveMcpApps(mcpAppsEnabled, flags), mcpApps: this.resolveMcpApps(mcpAppsEnabled, flags),
canvasGroupsEnabled: mcpCanvasGroupsEnabled || flags[MCP_CANVAS_GROUPS_FLAG] === true,
instanceContextEnabled: instanceContextEnabled:
mcpInstanceContextEnabled || flags[MCP_INSTANCE_CONTEXT_FLAG] === true, mcpInstanceContextEnabled || flags[MCP_INSTANCE_CONTEXT_FLAG] === true,
// Multivariate flag: only the `variant` arm enables the feature. // Multivariate flag: only the `variant` arm enables the feature.
@@ -454,7 +449,6 @@ export class McpService {
instructions: getMcpInstructions({ instructions: getMcpInstructions({
isBuilderEnabled: builderInstructionsEnabled, isBuilderEnabled: builderInstructionsEnabled,
isN8nConnectAvailable: n8nConnectAvailable, isN8nConnectAvailable: n8nConnectAvailable,
canvasGroupsEnabled: featureFlags.canvasGroupsEnabled,
isAgentsEnabled: agentInstructionsEnabled, isAgentsEnabled: agentInstructionsEnabled,
isUserPreferencesEnabled: userPreferencesInstructionsEnabled, isUserPreferencesEnabled: userPreferencesInstructionsEnabled,
}), }),
@@ -749,9 +743,7 @@ export class McpService {
); );
registerIfAllowed(getNodeTypesTool); registerIfAllowed(getNodeTypesTool);
const bestPracticesTool = createGetWorkflowBestPracticesTool(user, this.telemetry, { const bestPracticesTool = createGetWorkflowBestPracticesTool(user, this.telemetry);
canvasGroupsEnabled: featureFlags.canvasGroupsEnabled,
});
registerIfAllowed(bestPracticesTool); registerIfAllowed(bestPracticesTool);
const exploreNodeResourcesTool = createExploreNodeResourcesTool( const exploreNodeResourcesTool = createExploreNodeResourcesTool(
@@ -761,9 +753,7 @@ export class McpService {
); );
registerIfAllowed(exploreNodeResourcesTool); registerIfAllowed(exploreNodeResourcesTool);
const validateTool = createValidateWorkflowCodeTool(user, this.telemetry, this.nodeTypes, { const validateTool = createValidateWorkflowCodeTool(user, this.telemetry, this.nodeTypes);
canvasGroupsEnabled: featureFlags.canvasGroupsEnabled,
});
registerIfAllowed(validateTool); registerIfAllowed(validateTool);
const validateNodeTool = createValidateNodeTool(user, this.telemetry); const validateNodeTool = createValidateNodeTool(user, this.telemetry);
@@ -780,7 +770,6 @@ export class McpService {
this.projectRepository, this.projectRepository,
dataTableOps, dataTableOps,
this.aiGatewayService, this.aiGatewayService,
{ canvasGroupsEnabled: featureFlags.canvasGroupsEnabled },
this.logger, this.logger,
this.postSaveMetrics, this.postSaveMetrics,
); );
@@ -885,7 +874,6 @@ export class McpService {
this.subworkflowPolicyChecker, this.subworkflowPolicyChecker,
this.workflowPublishedDataService, this.workflowPublishedDataService,
this.aiGatewayService, this.aiGatewayService,
{ canvasGroupsEnabled: featureFlags.canvasGroupsEnabled },
this.logger, this.logger,
this.postSaveMetrics, this.postSaveMetrics,
); );
@@ -914,9 +902,7 @@ export class McpService {
{ {
uri: 'n8n://workflow-sdk/reference', uri: 'n8n://workflow-sdk/reference',
mimeType: 'text/plain', mimeType: 'text/plain',
text: getSdkReferenceContent(undefined, { text: getSdkReferenceContent(),
includeGroups: featureFlags.canvasGroupsEnabled,
}),
}, },
], ],
}), }),
@@ -924,9 +910,7 @@ export class McpService {
// SDK reference tool — always registered alongside the MCP resource above, // SDK reference tool — always registered alongside the MCP resource above,
// so all clients can access the SDK reference regardless of resource support. // so all clients can access the SDK reference regardless of resource support.
const sdkRefTool = createGetWorkflowSdkReferenceTool(user, this.telemetry, { const sdkRefTool = createGetWorkflowSdkReferenceTool(user, this.telemetry);
canvasGroupsEnabled: featureFlags.canvasGroupsEnabled,
});
registerIfAllowed(sdkRefTool); registerIfAllowed(sdkRefTool);
} }
@@ -181,7 +181,6 @@ export type UserConnectedToMCPEventPayload = {
http_status?: number; http_status?: number;
mcp_apps_enabled?: boolean; mcp_apps_enabled?: boolean;
mcp_apps_variant?: McpAppsTelemetryVariant; mcp_apps_variant?: McpAppsTelemetryVariant;
mcp_canvas_groups_enabled?: boolean;
error?: string; error?: string;
}; };
@@ -53,20 +53,6 @@ import {
const MAX_WORKFLOW_DESCRIPTION_LENGTH = 255; const MAX_WORKFLOW_DESCRIPTION_LENGTH = 255;
export type CreateWorkflowFromCodeToolOptions = {
/**
* `102_mcp_canvas_groups` rollout flag: when true, node groups authored in the
* SDK code (`.group(...)`) are persisted on the created workflow. Off by
* default — groups are then dropped at the entity assembly, exactly like
* before groups were supported. With the flag on, an invalid group does not
* fail the creation: it is dropped and reported in `skippedGroups` instead,
* while the rest of the workflow is still created. This tool pre-validates
* with the same rules `WorkflowCreationService.createWorkflow` enforces, so
* that shared service's own (fatal) group check never actually triggers here.
*/
canvasGroupsEnabled?: boolean;
};
function normalizeWorkflowDescription(description?: string) { function normalizeWorkflowDescription(description?: string) {
if (!description) return { description: undefined, truncated: false }; if (!description) return { description: undefined, truncated: false };
if (description.length <= MAX_WORKFLOW_DESCRIPTION_LENGTH) { if (description.length <= MAX_WORKFLOW_DESCRIPTION_LENGTH) {
@@ -325,7 +311,6 @@ export const createCreateWorkflowFromCodeTool = (
projectRepository: ProjectRepository, projectRepository: ProjectRepository,
dataTableOps: DataTableUserOperations, dataTableOps: DataTableUserOperations,
aiGatewayService: AiGatewayService, aiGatewayService: AiGatewayService,
options: CreateWorkflowFromCodeToolOptions = {},
logger: Logger, logger: Logger,
postSaveMetrics: McpPostSaveMetricsService, postSaveMetrics: McpPostSaveMetricsService,
): ToolDefinition<typeof inputSchema> => ({ ): ToolDefinition<typeof inputSchema> => ({
@@ -424,8 +409,7 @@ export const createCreateWorkflowFromCodeTool = (
...(workflowDescription ? { description: workflowDescription } : {}), ...(workflowDescription ? { description: workflowDescription } : {}),
nodes: workflowJson.nodes, nodes: workflowJson.nodes,
connections: workflowJson.connections, connections: workflowJson.connections,
// Flag off: groups keep being dropped here, exactly like before. nodeGroups: workflowJson.nodeGroups ?? [],
...(options.canvasGroupsEnabled ? { nodeGroups: workflowJson.nodeGroups ?? [] } : {}),
settings: { ...workflowJson.settings, executionOrder: 'v1', availableInMCP: true }, settings: { ...workflowJson.settings, executionOrder: 'v1', availableInMCP: true },
pinData: workflowJson.pinData, pinData: workflowJson.pinData,
meta: { ...workflowJson.meta, aiBuilderAssisted: true, builderVariant: 'mcp' }, meta: { ...workflowJson.meta, aiBuilderAssisted: true, builderVariant: 'mcp' },
@@ -440,11 +424,10 @@ export const createCreateWorkflowFromCodeTool = (
// parser above. Validate them here, before the shared persistence layer's // parser above. Validate them here, before the shared persistence layer's
// own (fatal) check, so an invalid group is dropped and reported instead // own (fatal) check, so an invalid group is dropped and reported instead
// of aborting the whole creation. // of aborting the whole creation.
const skippedGroups = options.canvasGroupsEnabled const skippedGroups = dropInvalidWorkflowGroups(
? dropInvalidWorkflowGroups(newWorkflow, makeGetNodeTypeForGrouping(nodeTypes)).map( newWorkflow,
(violation) => ({ groupName: violation.groupName, reason: violation.message }), makeGetNodeTypeForGrouping(nodeTypes),
) ).map((violation) => ({ groupName: violation.groupName, reason: violation.message }));
: [];
landingProject = projectId landingProject = projectId
? await projectRepository.findOneBy({ id: projectId }) ? await projectRepository.findOneBy({ id: projectId })
@@ -539,10 +522,7 @@ export const createCreateWorkflowFromCodeTool = (
skippedGroups: skippedGroups.length > 0 ? skippedGroups : undefined, skippedGroups: skippedGroups.length > 0 ? skippedGroups : undefined,
}; };
// Groups are dropped on save when the flag is off, so only warn when they can be kept. const ceilingWarning = topLevelItemsWarning(savedWorkflow);
const ceilingWarning = options.canvasGroupsEnabled
? topLevelItemsWarning(savedWorkflow)
: undefined;
const warnings = ceilingWarning ? [...result.warnings, ceilingWarning] : result.warnings; const warnings = ceilingWarning ? [...result.warnings, ceilingWarning] : result.warnings;
const output = warnings.length > 0 ? { ...baseOutput, warnings } : baseOutput; const output = warnings.length > 0 ? { ...baseOutput, warnings } : baseOutput;
@@ -566,11 +546,7 @@ export const createCreateWorkflowFromCodeTool = (
data: { data: {
workflowId: savedWorkflow.id, workflowId: savedWorkflow.id,
nodeCount: savedWorkflow.nodes.length, nodeCount: savedWorkflow.nodes.length,
// Rollout monitoring for `102_mcp_canvas_groups`; absent when the groupCount: workflowJson.nodeGroups?.length ?? 0,
// flag is off so the payload stays identical across cohorts.
...(options.canvasGroupsEnabled
? { groupCount: workflowJson.nodeGroups?.length ?? 0 }
: {}),
}, },
}; };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
@@ -49,7 +49,7 @@ const outputSchema = {
.describe('All available techniques, returned when "list" was requested.'), .describe('All available techniques, returned when "list" was requested.'),
} satisfies z.ZodRawShape; } satisfies z.ZodRawShape;
function buildListResponse(canvasGroupsEnabled: boolean) { function buildListResponse() {
const availableTechniques = Object.entries(TechniqueDescription).map(([key, description]) => ({ const availableTechniques = Object.entries(TechniqueDescription).map(([key, description]) => ({
technique: key, technique: key,
description, description,
@@ -66,9 +66,8 @@ function buildListResponse(canvasGroupsEnabled: boolean) {
(t) => (t) =>
`- ${t.technique}${t.hasDocumentation ? '' : ' (no detailed documentation yet)'} — ${t.description}`, `- ${t.technique}${t.hasDocumentation ? '' : ' (no detailed documentation yet)'} — ${t.description}`,
), ),
// Grouping guidance is flag-gated and appended last, so flag-off output is '',
// unchanged. GROUPING_GUIDANCE,
...(canvasGroupsEnabled ? ['', GROUPING_GUIDANCE] : []),
].join('\n'); ].join('\n');
return { return {
@@ -110,7 +109,6 @@ function buildTechniqueResponse(technique: WorkflowTechniqueType) {
export const createGetWorkflowBestPracticesTool = ( export const createGetWorkflowBestPracticesTool = (
user: User, user: User,
telemetry: Telemetry, telemetry: Telemetry,
{ canvasGroupsEnabled }: { canvasGroupsEnabled: boolean },
): ToolDefinition<typeof inputSchema> => ({ ): ToolDefinition<typeof inputSchema> => ({
name: MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName, name: MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName,
config: { config: {
@@ -135,9 +133,7 @@ export const createGetWorkflowBestPracticesTool = (
try { try {
const response = const response =
technique === LIST_SENTINEL technique === LIST_SENTINEL ? buildListResponse() : buildTechniqueResponse(technique);
? buildListResponse(canvasGroupsEnabled)
: buildTechniqueResponse(technique);
telemetryPayload.results = { telemetryPayload.results = {
success: true, success: true,
@@ -8,7 +8,7 @@ import { getSdkReferenceContent, type SdkReferenceSection } from './sdk-referenc
import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types';
const BASE_SECTIONS: SdkReferenceSection[] = [ const VALID_SECTIONS: SdkReferenceSection[] = [
'patterns', 'patterns',
'patterns_detailed', 'patterns_detailed',
'expressions', 'expressions',
@@ -17,28 +17,18 @@ const BASE_SECTIONS: SdkReferenceSection[] = [
'import', 'import',
'guidelines', 'guidelines',
'design', 'design',
'groups',
'all', 'all',
]; ];
// `'groups'` is only advertised (and accepted) when the canvas-groups flag is on, const inputSchema = {
// so with the flag off the accepted `section` values are exactly what they were section: z
// before groups existed. .enum(VALID_SECTIONS as [string, ...string[]])
const buildInputSchema = (canvasGroupsEnabled: boolean) => { .optional()
const validSections: SdkReferenceSection[] = canvasGroupsEnabled .describe(
? [...BASE_SECTIONS, 'groups'] 'Optional section to retrieve. Omit this for the full reference, or use a section for targeted lookup.',
: BASE_SECTIONS; ),
} satisfies z.ZodRawShape;
return {
section: z
.enum(validSections as [string, ...string[]])
.optional()
.describe(
'Optional section to retrieve. Omit this for the full reference, or use a section for targeted lookup.',
),
} satisfies z.ZodRawShape;
};
type SdkReferenceInputSchema = ReturnType<typeof buildInputSchema>;
const outputSchema = { const outputSchema = {
reference: z.string().describe('SDK reference documentation content for the requested section'), reference: z.string().describe('SDK reference documentation content for the requested section'),
@@ -51,13 +41,12 @@ const outputSchema = {
export const createGetWorkflowSdkReferenceTool = ( export const createGetWorkflowSdkReferenceTool = (
user: User, user: User,
telemetry: Telemetry, telemetry: Telemetry,
{ canvasGroupsEnabled }: { canvasGroupsEnabled: boolean }, ): ToolDefinition<typeof inputSchema> => ({
): ToolDefinition<SdkReferenceInputSchema> => ({
name: MCP_GET_SDK_REFERENCE_TOOL.toolName, name: MCP_GET_SDK_REFERENCE_TOOL.toolName,
config: { config: {
description: description:
'Required reference when building a workflow, and only then. Call this BEFORE writing workflow code to learn workflow(), trigger()/node(), .add()/.to(), expr(), and credential patterns.', 'Required reference when building a workflow, and only then. Call this BEFORE writing workflow code to learn workflow(), trigger()/node(), .add()/.to(), expr(), and credential patterns.',
inputSchema: buildInputSchema(canvasGroupsEnabled), inputSchema,
outputSchema, outputSchema,
annotations: { annotations: {
title: MCP_GET_SDK_REFERENCE_TOOL.displayTitle, title: MCP_GET_SDK_REFERENCE_TOOL.displayTitle,
@@ -74,9 +63,7 @@ export const createGetWorkflowSdkReferenceTool = (
parameters: { section }, parameters: { section },
}; };
const content = getSdkReferenceContent(section as SdkReferenceSection | undefined, { const content = getSdkReferenceContent(section as SdkReferenceSection | undefined);
includeGroups: canvasGroupsEnabled,
});
telemetryPayload.results = { success: true }; telemetryPayload.results = { success: true };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
@@ -37,12 +37,6 @@ export type McpInstructionsOptions = {
*/ */
isN8nConnectAvailable?: boolean; isN8nConnectAvailable?: boolean;
/**
* Whether canvas node groups are enabled for this user.
* If true, the builder instructions point the client at the groups docs.
*/
canvasGroupsEnabled?: boolean;
/** /**
* Whether first-class Agent tools are enabled on this MCP server. * Whether first-class Agent tools are enabled on this MCP server.
* If true, the instructions include Agent build guidance and artifact routing. * If true, the instructions include Agent build guidance and artifact routing.
@@ -60,7 +54,6 @@ export function getMcpInstructions(options: McpInstructionsOptions): string {
const { const {
isBuilderEnabled, isBuilderEnabled,
isN8nConnectAvailable = false, isN8nConnectAvailable = false,
canvasGroupsEnabled = false,
isAgentsEnabled = false, isAgentsEnabled = false,
isUserPreferencesEnabled = false, isUserPreferencesEnabled = false,
} = options; } = options;
@@ -72,12 +65,9 @@ export function getMcpInstructions(options: McpInstructionsOptions): string {
? `Before ${MCP_USER_PREFERENCES_TRIGGER_CLAUSE} call ${MCP_GET_USER_PREFERENCES_TOOL_NAME} first and apply what it returns for the remainder of the task.` ? `Before ${MCP_USER_PREFERENCES_TRIGGER_CLAUSE} call ${MCP_GET_USER_PREFERENCES_TOOL_NAME} first and apply what it returns for the remainder of the task.`
: ''; : '';
// Only appended when the flag is on; keeps the paid-per-session string short. const GROUPS_HINT = `
const GROUPS_HINT = canvasGroupsEnabled
? `
Node groups: when a workflow has several distinct stages, organise it into named groups so it is readable on the canvas. Before creating groups, call ${MCP_GET_SDK_REFERENCE_TOOL.toolName} with section "groups" for the rules, and ${MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName} (technique "list") for when to group. The save never fails because of groups, so read its result: when it reports TOP_LEVEL_ITEMS_OVER_CEILING, skippedGroups or removedGroups, repair the groups with ${MCP_UPDATE_WORKFLOW_TOOL.toolName} before you tell the user the workflow is done. A warning marked [pre-existing] describes a canvas that was already like that before your update; you do not need to repair it before you report done.` Node groups: when a workflow has several distinct stages, organise it into named groups so it is readable on the canvas. Before creating groups, call ${MCP_GET_SDK_REFERENCE_TOOL.toolName} with section "groups" for the rules, and ${MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName} (technique "list") for when to group. The save never fails because of groups, so read its result: when it reports TOP_LEVEL_ITEMS_OVER_CEILING, skippedGroups or removedGroups, repair the groups with ${MCP_UPDATE_WORKFLOW_TOOL.toolName} before you tell the user the workflow is done. A warning marked [pre-existing] describes a canvas that was already like that before your update; you do not need to repair it before you report done.`;
: '';
const N8N_CONNECT_HINT = isN8nConnectAvailable const N8N_CONNECT_HINT = isN8nConnectAvailable
? ` ? `
@@ -118,7 +108,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. 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.
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, setWorkflowMetadata, setWorkflowSettings, setNodeGroups, addNodeGroup, removeNodeGroup, updateNodeGroup). The whole batch is atomic: if any op fails the workflow is unchanged, except node-group operations, which are skipped and reported in skippedOperations. 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.
11. Archive: Call ${MCP_ARCHIVE_WORKFLOW_TOOL.toolName} with the workflow ID. 11. Archive: Call ${MCP_ARCHIVE_WORKFLOW_TOOL.toolName} with the workflow ID.
@@ -14,7 +14,7 @@ import {
ADDITIONAL_FUNCTIONS, ADDITIONAL_FUNCTIONS,
WORKFLOW_RULES, WORKFLOW_RULES,
NODE_GROUPS_REFERENCE, NODE_GROUPS_REFERENCE,
buildSdkLanguageReference, SDK_LANGUAGE_REFERENCE,
} from '@n8n/workflow-sdk/prompts/sdk-reference'; } from '@n8n/workflow-sdk/prompts/sdk-reference';
// NOTE: CODING_GUIDELINES and DESIGN_GUIDANCE are MCP-only constants defined // NOTE: CODING_GUIDELINES and DESIGN_GUIDANCE are MCP-only constants defined
@@ -90,23 +90,8 @@ const SECTIONS: Record<Exclude<SdkReferenceSection, 'all'>, string> = {
/** /**
* Get the full SDK reference content or a filtered section. * Get the full SDK reference content or a filtered section.
*
* Node-group docs are gated behind `includeGroups` (fed from the
* `canvasGroupsEnabled` feature flag): when false, no group content is served
* anywhere in the output.
*/ */
export function getSdkReferenceContent( export function getSdkReferenceContent(section?: SdkReferenceSection): string {
section?: SdkReferenceSection,
options: { includeGroups?: boolean } = {},
): string {
const { includeGroups = false } = options;
// The groups section only exists when the flag is on; otherwise even an
// explicit request yields nothing.
if (section === 'groups') {
return includeGroups ? SECTIONS.groups : '';
}
if (section && section !== 'all' && section in SECTIONS) { if (section && section !== 'all' && section in SECTIONS) {
return SECTIONS[section]; return SECTIONS[section];
} }
@@ -117,9 +102,9 @@ export function getSdkReferenceContent(
SECTIONS.import, SECTIONS.import,
'', '',
// Language rules for the restricted SDK subset (what the AST interpreter // Language rules for the restricted SDK subset (what the AST interpreter
// accepts). The flag-gated node-groups docs ride inside it, so this is the // accepts). The node-groups docs ride inside it, so this is the single
// single place group content enters the full reference. // place group content enters the full reference.
buildSdkLanguageReference({ includeGroups }), SDK_LANGUAGE_REFERENCE,
'', '',
SECTIONS.patterns, SECTIONS.patterns,
'', '',
@@ -66,7 +66,7 @@ const MAX_OPERATIONS_PER_CALL = 100;
// which drops `undefined` properties. `deepCopy` preserves them, breaking recovery. // which drops `undefined` properties. `deepCopy` preserves them, breaking recovery.
// eslint-disable-next-line n8n-local-rules/no-json-parse-json-stringify // eslint-disable-next-line n8n-local-rules/no-json-parse-json-stringify
const normalize = (value: unknown) => JSON.parse(JSON.stringify(value ?? null)); const normalize = (value: unknown) => JSON.parse(JSON.stringify(value ?? null));
const baseOperationTypes = [ const operationTypes = [
'updateNodeParameters', 'updateNodeParameters',
'setNodeParameter', 'setNodeParameter',
'addNode', 'addNode',
@@ -83,21 +83,14 @@ const baseOperationTypes = [
'addTags', 'addTags',
'removeTags', 'removeTags',
'setNodeGroups', 'setNodeGroups',
] as const satisfies ReadonlyArray<PartialUpdateOperation['type']>;
// Granular group ops roll out behind the `102_mcp_canvas_groups` flag;
// `setNodeGroups` predates the flag and stays ungated.
const gatedGroupOperationTypes = [
'addNodeGroup', 'addNodeGroup',
'removeNodeGroup', 'removeNodeGroup',
'updateNodeGroup', 'updateNodeGroup',
] as const satisfies ReadonlyArray<PartialUpdateOperation['type']>; ] as const satisfies ReadonlyArray<PartialUpdateOperation['type']>;
// The `satisfies` on both tuples above is what catches a renamed operation type // The `satisfies` above is what catches a renamed operation type in
// in workflow-operations.ts at compile time, instead of silently leaving the gate // workflow-operations.ts at compile time, instead of silently leaving an
// unmatched or an implemented operation unreachable. The set element type is // implemented operation unreachable.
// narrowed to match so `.has()` only accepts a real operation type. const operationTypeSchema = z.enum(operationTypes);
const GATED_GROUP_OP_TYPES: ReadonlySet<PartialUpdateOperation['type']> = new Set(
gatedGroupOperationTypes,
);
const GRAPH_OPERATION_TYPES: ReadonlySet<PartialUpdateOperation['type']> = new Set([ const GRAPH_OPERATION_TYPES: ReadonlySet<PartialUpdateOperation['type']> = new Set([
'addNode', 'addNode',
'removeNode', 'removeNode',
@@ -115,10 +108,6 @@ const GRAPH_OPERATION_TYPES: ReadonlySet<PartialUpdateOperation['type']> = new S
'updateNodeGroup', 'updateNodeGroup',
'setNodeSettings', 'setNodeSettings',
]); ]);
const buildOperationTypeSchema = (canvasGroupsEnabled: boolean) =>
canvasGroupsEnabled
? z.enum([...baseOperationTypes, ...gatedGroupOperationTypes])
: z.enum(baseOperationTypes);
// A factory, not a shared instance: reusing one Zod instance across two // A factory, not a shared instance: reusing one Zod instance across two
// properties makes the JSON Schema generator dedupe the second occurrence into // properties makes the JSON Schema generator dedupe the second occurrence into
// a `$ref` to a `#/properties/...` path, which strict MCP clients cannot // a `$ref` to a `#/properties/...` path, which strict MCP clients cannot
@@ -165,102 +154,83 @@ const combinedSettingsInputSchema = z
.describe( .describe(
'Settings to write. For setNodeSettings use the node-level keys (onError, retryOnFail, maxTries, waitBetweenTries, alwaysOutputData, executeOnce). For setWorkflowSettings use the workflow-level keys (errorWorkflow, timezone, executionOrder, saveExecutionProgress, saveManualExecutions, saveDataErrorExecution, saveDataSuccessExecution, executionTimeout, timeSavedPerExecution, callerPolicy, callerIds). Provide only the keys for the operation you are running.', 'Settings to write. For setNodeSettings use the node-level keys (onError, retryOnFail, maxTries, waitBetweenTries, alwaysOutputData, executeOnce). For setWorkflowSettings use the workflow-level keys (errorWorkflow, timezone, executionOrder, saveExecutionProgress, saveManualExecutions, saveDataErrorExecution, saveDataSuccessExecution, executionTimeout, timeSavedPerExecution, callerPolicy, callerIds). Provide only the keys for the operation you are running.',
); );
const buildOperationInputSchema = (canvasGroupsEnabled: boolean) => const operationInputSchema = z
z .object({
.object({ type: operationTypeSchema.describe('Operation type.'),
type: buildOperationTypeSchema(canvasGroupsEnabled).describe('Operation type.'), nodeName: z.string().optional().describe('For node-targeted ops.'),
nodeName: z.string().optional().describe('For node-targeted ops.'), node: nodeInputSchema.optional().describe('For addNode.'),
node: nodeInputSchema.optional().describe('For addNode.'), parameters: z.record(z.string(), z.unknown()).optional().describe('For updateNodeParameters.'),
parameters: z replace: z.boolean().optional().describe('For updateNodeParameters; default false.'),
.record(z.string(), z.unknown()) path: z.string().min(2).optional().describe('For setNodeParameter; JSON Pointer path.'),
.optional() value: z.unknown().optional().describe('For setNodeParameter.'),
.describe('For updateNodeParameters.'), oldName: z.string().optional().describe('For renameNode.'),
replace: z.boolean().optional().describe('For updateNodeParameters; default false.'), newName: z.string().optional().describe('For renameNode or updateNodeGroup.'),
path: z.string().min(2).optional().describe('For setNodeParameter; JSON Pointer path.'), source: z.string().optional().describe('For connection ops.'),
value: z.unknown().optional().describe('For setNodeParameter.'), target: z.string().optional().describe('For connection ops.'),
oldName: z.string().optional().describe('For renameNode.'), sourceIndex: z
newName: z .number()
.string() .int()
.optional() .nonnegative()
.describe(canvasGroupsEnabled ? 'For renameNode or updateNodeGroup.' : 'For renameNode.'), .optional()
source: z.string().optional().describe('For connection ops.'), .describe(
target: z.string().optional().describe('For connection ops.'), 'For connection ops; which output of the source node the connection starts from. Default 0, the first output. Use it to wire a branch: on an If node the false branch is index 1, and onError "continueErrorOutput" appends an error output after the regular ones (index 1 on a single-output node such as HTTP Request, index 2 on an If node). This is the only field that selects an output.',
sourceIndex: z ),
.number() targetIndex: z
.int() .number()
.nonnegative() .int()
.optional() .nonnegative()
.describe( .optional()
'For connection ops; which output of the source node the connection starts from. Default 0, the first output. Use it to wire a branch: on an If node the false branch is index 1, and onError "continueErrorOutput" appends an error output after the regular ones (index 1 on a single-output node such as HTTP Request, index 2 on an If node). This is the only field that selects an output.', .describe(
), 'For connection ops; which input of the target node the connection ends at. Default 0.',
targetIndex: z ),
.number() connectionType: z.string().optional().describe('For connection ops; default "main".'),
.int() credentialKey: z.string().optional().describe('For setNodeCredential.'),
.nonnegative() credentialId: z.string().optional().describe('For setNodeCredential.'),
.optional() credentialName: z.string().optional().describe('For setNodeCredential.'),
.describe( position: positionInputSchema().optional().describe('For setNodePosition.'),
'For connection ops; which input of the target node the connection ends at. Default 0.', disabled: z.boolean().optional().describe('For setNodeDisabled.'),
), settings: combinedSettingsInputSchema
connectionType: z.string().optional().describe('For connection ops; default "main".'), .optional()
credentialKey: z.string().optional().describe('For setNodeCredential.'), .describe('For setNodeSettings or setWorkflowSettings.'),
credentialId: z.string().optional().describe('For setNodeCredential.'), name: z
credentialName: z.string().optional().describe('For setNodeCredential.'), .string()
position: positionInputSchema().optional().describe('For setNodePosition.'), .max(128)
disabled: z.boolean().optional().describe('For setNodeDisabled.'), .optional()
settings: combinedSettingsInputSchema .describe('For setWorkflowMetadata (workflow name) or addNodeGroup (group name).'),
.optional() description: z
.describe('For setNodeSettings or setWorkflowSettings.'), .string()
name: z .max(255)
.string() .optional()
.max(128) .describe('For setWorkflowMetadata, addNodeGroup, or updateNodeGroup.'),
.optional() names: z.array(z.string()).optional().describe('For addTags / removeTags.'),
.describe( nodeGroups: z
canvasGroupsEnabled .array(
? 'For setWorkflowMetadata (workflow name) or addNodeGroup (group name).' z.object({
: 'Only used for setWorkflowMetadata.', id: z.string().optional(),
), name: z.string(),
description: z nodeNames: z.array(z.string()),
.string() description: z.string().optional(),
.max(255) }),
.optional() )
.describe( .optional()
canvasGroupsEnabled .describe(
? 'For setWorkflowMetadata, addNodeGroup, or updateNodeGroup.' 'For setNodeGroups. Replaces all node groups; pass [] to clear. Group members are node names, not ids.',
: 'Only used for setWorkflowMetadata.', ),
), groupName: z.string().optional().describe('For removeNodeGroup / updateNodeGroup.'),
names: z.array(z.string()).optional().describe('For addTags / removeTags.'), nodeNames: z
nodeGroups: z .array(z.string())
.array( .optional()
z.object({ .describe('For addNodeGroup / updateNodeGroup; group member node names.'),
id: z.string().optional(), id: z.string().optional().describe('For addNodeGroup; group id, generated if omitted.'),
name: z.string(), })
nodeNames: z.array(z.string()), // Strict, so a field this schema does not declare fails the call instead of
description: z.string().optional(), // being stripped. Stripping made the tool report success for an operation it
}), // never ran: a guessed output-index field (e.g. sourceOutput) vanished and
) // the connection was wired from output 0.
.optional() .strict()
.describe( .describe('Workflow update operation. Provide fields matching type.');
'For setNodeGroups. Replaces all node groups; pass [] to clear. Group members are node names, not ids.',
),
...(canvasGroupsEnabled
? {
groupName: z.string().optional().describe('For removeNodeGroup / updateNodeGroup.'),
nodeNames: z
.array(z.string())
.optional()
.describe('For addNodeGroup / updateNodeGroup; group member node names.'),
id: z.string().optional().describe('For addNodeGroup; group id, generated if omitted.'),
}
: {}),
})
// Strict, so a field this schema does not declare fails the call instead of
// being stripped. Stripping made the tool report success for an operation it
// never ran: a guessed output-index field (e.g. sourceOutput) vanished and
// the connection was wired from output 0.
.strict()
.describe('Workflow update operation. Provide fields matching type.');
type OperationInput = { type OperationInput = {
type: (typeof baseOperationTypes)[number] | (typeof gatedGroupOperationTypes)[number]; type: (typeof operationTypes)[number];
[key: string]: unknown; [key: string]: unknown;
}; };
const strictOperationsSchema = z.array(partialUpdateOperationSchema); const strictOperationsSchema = z.array(partialUpdateOperationSchema);
@@ -282,36 +252,24 @@ function parseStrictOperations(operations: OperationInput[]): PartialUpdateOpera
} }
const NON_FATAL_OPERATION_TYPES_LIST = [...NON_FATAL_OPERATION_TYPES].join(', '); const NON_FATAL_OPERATION_TYPES_LIST = [...NON_FATAL_OPERATION_TYPES].join(', ');
const buildToolDescription = (canvasGroupsEnabled: boolean) => { const TOOL_DESCRIPTION = `Atomically update an existing workflow with operation objects. Edits nodes/connections and also workflow-level settings via setWorkflowSettings — including the error workflow that runs automatically on failure to send alerts (e.g. when a user asks to "add error handling" or "notify me if this breaks"). Pass skillsUsed if n8n skills were used. Node-group operations (${NON_FATAL_OPERATION_TYPES_LIST}) are the one exception to "atomically": an invalid one is skipped and reported in skippedOperations instead of aborting the whole update. Separately, if other edits in the batch make an existing group invalid, that group is removed and reported in removedGroups.`;
const base = const inputSchema = {
'Atomically update an existing workflow with operation objects. Edits nodes/connections and also workflow-level settings via setWorkflowSettings — including the error workflow that runs automatically on failure to send alerts (e.g. when a user asks to "add error handling" or "notify me if this breaks"). Pass skillsUsed if n8n skills were used.'; workflowId: z.string().describe('The ID of the workflow to update.'),
return canvasGroupsEnabled skillsUsed: z.array(z.string()).optional().describe(SKILLS_USED_PARAM_DESCRIPTION),
? `${base} Node-group operations (${NON_FATAL_OPERATION_TYPES_LIST}) are the one exception to "atomically": an invalid one is skipped and reported in skippedOperations instead of aborting the whole update. Separately, if other edits in the batch make an existing group invalid, that group is removed and reported in removedGroups.` operations: z
: base; .array(operationInputSchema)
}; .min(1)
// The concrete return type (not a widened z.ZodRawShape) keeps the tool's .max(MAX_OPERATIONS_PER_CALL)
// generic coupled to the real schema shape, so the handler's argument .describe(
// annotation is compile-checked against it via ToolHandler's parameter types. `Ordered operations to apply atomically (max ${MAX_OPERATIONS_PER_CALL}). If any op fails, nothing is saved — except node-group operations (${NON_FATAL_OPERATION_TYPES_LIST}): an invalid one is skipped and reported in skippedOperations, while the rest of the batch still saves. An existing group that these ops leave invalid is removed and reported in removedGroups.`,
const buildInputSchema = (canvasGroupsEnabled: boolean) =>
({
workflowId: z.string().describe('The ID of the workflow to update.'),
skillsUsed: z.array(z.string()).optional().describe(SKILLS_USED_PARAM_DESCRIPTION),
operations: z
.array(buildOperationInputSchema(canvasGroupsEnabled))
.min(1)
.max(MAX_OPERATIONS_PER_CALL)
.describe(
canvasGroupsEnabled
? `Ordered operations to apply atomically (max ${MAX_OPERATIONS_PER_CALL}). If any op fails, nothing is saved — except node-group operations (${NON_FATAL_OPERATION_TYPES_LIST}): an invalid one is skipped and reported in skippedOperations, while the rest of the batch still saves. An existing group that these ops leave invalid is removed and reported in removedGroups.`
: `Ordered operations to apply atomically (max ${MAX_OPERATIONS_PER_CALL}). If any op fails, nothing is saved.`,
),
versionName: versionNameInputSchema.describe(
'Short summary of what this update changes, shown in the workflow\'s version history (e.g. "Added Slack notification after HTTP request"). Always provide it.',
), ),
versionDescription: versionDescriptionInputSchema.describe( versionName: versionNameInputSchema.describe(
'Longer description of what changed and why, shown in the version history alongside the version name.', 'Short summary of what this update changes, shown in the workflow\'s version history (e.g. "Added Slack notification after HTTP request"). Always provide it.',
), ),
}) satisfies z.ZodRawShape; versionDescription: versionDescriptionInputSchema.describe(
'Longer description of what changed and why, shown in the version history alongside the version name.',
),
} satisfies z.ZodRawShape;
// The MCP SDK publishes this schema with `additionalProperties: false` and // The MCP SDK publishes this schema with `additionalProperties: false` and
// validates `structuredContent` against it on every response. Success returns // validates `structuredContent` against it on every response. Success returns
// the full payload below; the error path returns only `{ error }`. To keep // the full payload below; the error path returns only `{ error }`. To keep
@@ -824,23 +782,12 @@ const isSettingsOperation = (op: PartialUpdateOperation) => op.type === 'setWork
/** /**
* Rejects operations this instance cannot serve, before anything is loaded or * Rejects operations this instance cannot serve, before anything is loaded or
* applied. Throw order is part of the contract: gated group ops first, then * applied.
* tag ops.
*/ */
function assertOperationsSupported( function assertOperationsSupported(
strictOperations: PartialUpdateOperation[], strictOperations: PartialUpdateOperation[],
{ canvasGroupsEnabled, tagsDisabled }: { canvasGroupsEnabled: boolean; tagsDisabled: boolean }, { tagsDisabled }: { tagsDisabled: boolean },
): void { ): void {
// Defense in depth: with the flag off, the published schema already
// rejects these op types at the enum level; this guards against the
// loose and strict schemas drifting apart. Flag first so the scan only
// runs on instances where it can actually reject something.
if (!canvasGroupsEnabled && strictOperations.some((op) => GATED_GROUP_OP_TYPES.has(op.type))) {
throw new Error(
'Node group operations (addNodeGroup, removeNodeGroup, updateNodeGroup) are not available on this instance.',
);
}
if (tagsDisabled && strictOperations.some(isTagOperation)) { if (tagsDisabled && strictOperations.some(isTagOperation)) {
throw new Error('Tag operations are not supported on this instance because tags are disabled.'); throw new Error('Tag operations are not supported on this instance because tags are disabled.');
} }
@@ -858,7 +805,6 @@ function assertOperationsSupported(
*/ */
function resolveNodeGroupViolations( function resolveNodeGroupViolations(
result: ApplyOperationsSuccess, result: ApplyOperationsSuccess,
canvasGroupsEnabled: boolean,
nodeTypes: NodeTypes, nodeTypes: NodeTypes,
): { ): {
skippedOperations: SkippedOperation[]; skippedOperations: SkippedOperation[];
@@ -877,16 +823,14 @@ function resolveNodeGroupViolations(
// groups this batch touched, not which one caused a given violation — two group // groups this batch touched, not which one caused a given violation — two group
// ops that collide take each other down. // ops that collide take each other down.
const getNodeType = makeGetNodeTypeForGrouping(nodeTypes); const getNodeType = makeGetNodeTypeForGrouping(nodeTypes);
const violations = canvasGroupsEnabled const violations = [
? [ ...dropInvalidWorkflowGroups(
...dropInvalidWorkflowGroups( result.workflow,
result.workflow, getNodeType,
getNodeType, (violation) => result.groupOperations[violation.groupId] !== undefined,
(violation) => result.groupOperations[violation.groupId] !== undefined, ),
), ...dropInvalidWorkflowGroups(result.workflow, getNodeType),
...dropInvalidWorkflowGroups(result.workflow, getNodeType), ];
]
: [];
for (const violation of violations) { for (const violation of violations) {
const requestedBy = result.groupOperations[violation.groupId]; const requestedBy = result.groupOperations[violation.groupId];
@@ -1168,20 +1112,9 @@ export const createUpdateWorkflowTool = (
subworkflowPolicyChecker: SubworkflowPolicyChecker, subworkflowPolicyChecker: SubworkflowPolicyChecker,
workflowPublishedDataService: WorkflowPublishedDataService, workflowPublishedDataService: WorkflowPublishedDataService,
aiGatewayService: AiGatewayService, aiGatewayService: AiGatewayService,
options: {
/**
* `102_mcp_canvas_groups` rollout flag: when true, the granular node-group
* operations (addNodeGroup, removeNodeGroup, updateNodeGroup) are published
* in the tool schema and accepted by the handler. `setNodeGroups` predates
* the flag and is always available.
*/
canvasGroupsEnabled?: boolean;
} = {},
logger: Logger, logger: Logger,
postSaveMetrics: McpPostSaveMetricsService, postSaveMetrics: McpPostSaveMetricsService,
): ToolDefinition<ReturnType<typeof buildInputSchema>> => { ): ToolDefinition<typeof inputSchema> => {
const canvasGroupsEnabled = options.canvasGroupsEnabled === true;
// Bound once: these never vary per call, so the call sites below show only // Bound once: these never vary per call, so the call sites below show only
// what is being validated. // what is being validated.
const settingsGuardDependencies: WorkflowSettingsGuardDependencies = { const settingsGuardDependencies: WorkflowSettingsGuardDependencies = {
@@ -1196,8 +1129,8 @@ export const createUpdateWorkflowTool = (
return { return {
name: MCP_UPDATE_WORKFLOW_TOOL.toolName, name: MCP_UPDATE_WORKFLOW_TOOL.toolName,
config: { config: {
description: buildToolDescription(canvasGroupsEnabled), description: TOOL_DESCRIPTION,
inputSchema: buildInputSchema(canvasGroupsEnabled), inputSchema,
outputSchema, outputSchema,
annotations: { annotations: {
title: MCP_UPDATE_WORKFLOW_TOOL.displayTitle, title: MCP_UPDATE_WORKFLOW_TOOL.displayTitle,
@@ -1249,7 +1182,6 @@ export const createUpdateWorkflowTool = (
hasGraphOps = strictOperations.some((op) => GRAPH_OPERATION_TYPES.has(op.type)); hasGraphOps = strictOperations.some((op) => GRAPH_OPERATION_TYPES.has(op.type));
assertOperationsSupported(strictOperations, { assertOperationsSupported(strictOperations, {
canvasGroupsEnabled,
tagsDisabled: globalConfig.tags.disabled, tagsDisabled: globalConfig.tags.disabled,
}); });
@@ -1266,7 +1198,6 @@ export const createUpdateWorkflowTool = (
const result = applyOperations( const result = applyOperations(
toWorkflowSlice(existingWorkflow, { includeTags: hasTagOperations }), toWorkflowSlice(existingWorkflow, { includeTags: hasTagOperations }),
strictOperations, strictOperations,
{ canvasGroupsEnabled },
); );
if (!result.success) { if (!result.success) {
@@ -1274,7 +1205,7 @@ export const createUpdateWorkflowTool = (
} }
const { skippedOperations, removedGroups, nodeGroupsNeedPersisting } = const { skippedOperations, removedGroups, nodeGroupsNeedPersisting } =
resolveNodeGroupViolations(result, canvasGroupsEnabled, nodeTypes); resolveNodeGroupViolations(result, nodeTypes);
const credentialCheck = await validateCredentialReferences( const credentialCheck = await validateCredentialReferences(
strictOperations, strictOperations,
@@ -1405,12 +1336,9 @@ export const createUpdateWorkflowTool = (
result.groupOperations, result.groupOperations,
); );
// Groups are dropped on save when the flag is off, so only warn when they can be kept.
// A canvas that was already this wide before the update is marked pre-existing, // A canvas that was already this wide before the update is marked pre-existing,
// so the agent does not rework a layout it did not make. // so the agent does not rework a layout it did not make.
const ceilingWarning = canvasGroupsEnabled const ceilingWarning = topLevelItemsWarning(updatedWorkflow);
? topLevelItemsWarning(updatedWorkflow)
: undefined;
if (ceilingWarning) { if (ceilingWarning) {
const preExistingUngroupedNodeNames = new Set( const preExistingUngroupedNodeNames = new Set(
@@ -13,16 +13,6 @@ import { getSdkReferenceHint } from '../workflow-validation.utils';
import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check'; import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check';
import { CODE_BUILDER_VALIDATE_TOOL, MAX_WORKFLOW_CODE_LENGTH } from './constants'; import { CODE_BUILDER_VALIDATE_TOOL, MAX_WORKFLOW_CODE_LENGTH } from './constants';
export type ValidateWorkflowCodeToolOptions = {
/**
* `102_mcp_canvas_groups` rollout flag: when true, node-group rule violations
* are reported as validation errors (`valid: false`) — they hard-block the
* save path — and traced in telemetry. Off by default — the tool then
* behaves exactly as before groups were validated.
*/
canvasGroupsEnabled?: boolean;
};
const inputSchema = { const inputSchema = {
code: z code: z
.string() .string()
@@ -66,7 +56,6 @@ export const createValidateWorkflowCodeTool = (
user: User, user: User,
telemetry: Telemetry, telemetry: Telemetry,
nodeTypes: NodeTypes, nodeTypes: NodeTypes,
options: ValidateWorkflowCodeToolOptions = {},
): ToolDefinition<typeof inputSchema> => ({ ): ToolDefinition<typeof inputSchema> => ({
name: CODE_BUILDER_VALIDATE_TOOL.toolName, name: CODE_BUILDER_VALIDATE_TOOL.toolName,
config: { config: {
@@ -109,11 +98,10 @@ export const createValidateWorkflowCodeTool = (
); );
if (invalidToolSourceResponse) return invalidToolSourceResponse; if (invalidToolSourceResponse) return invalidToolSourceResponse;
// `102_mcp_canvas_groups` rollout: report node-group rule violations as // Report node-group rule violations as validation errors, with the same
// validation errors, with the same messages the save path rejects with — // messages the save path rejects with — like the ai_tool-source check
// like the ai_tool-source check above, they hard-block saving. Flag off: // above, they hard-block saving.
// output and telemetry are identical to before groups existed. if ((result.workflow.nodeGroups?.length ?? 0) > 0) {
if (options.canvasGroupsEnabled && (result.workflow.nodeGroups?.length ?? 0) > 0) {
const groupsResult = validateWorkflowGroups({ const groupsResult = validateWorkflowGroups({
nodes: toGroupValidationNodes(result.workflow.nodes), nodes: toGroupValidationNodes(result.workflow.nodes),
connectionsBySourceNode: toEngineConnections(result.workflow.connections), connectionsBySourceNode: toEngineConnections(result.workflow.connections),
@@ -150,11 +138,7 @@ export const createValidateWorkflowCodeTool = (
data: { data: {
nodeCount: result.workflow.nodes.length, nodeCount: result.workflow.nodes.length,
warningCount: result.warnings.length, warningCount: result.warnings.length,
// Rollout monitoring for `102_mcp_canvas_groups`; absent when the groupCount: result.workflow.nodeGroups?.length ?? 0,
// flag is off so the payload stays identical across cohorts.
...(options.canvasGroupsEnabled
? { groupCount: result.workflow.nodeGroups?.length ?? 0 }
: {}),
}, },
}; };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
@@ -1234,16 +1234,13 @@ const OPERATION_HANDLERS: { [K in PartialUpdateOperation['type']]: OpHandler<K>
* *
* The function never mutates the input. * The function never mutates the input.
* *
* With `{ canvasGroupsEnabled: true }`, a failing operation of a type in * A failing operation of a type in `NON_FATAL_OPERATION_TYPES` does not abort
* `NON_FATAL_OPERATION_TYPES` does not abort the batch — it is skipped and recorded * the batch — it is skipped and recorded in the result's `skippedOperations`
* in the result's `skippedOperations` instead, and the remaining operations still * instead, and the remaining operations still apply.
* apply. With the flag off (or omitted), every operation is fatal, matching the
* historical behavior exactly.
*/ */
export function applyOperations( export function applyOperations(
input: WorkflowSlice, input: WorkflowSlice,
operations: PartialUpdateOperation[], operations: PartialUpdateOperation[],
options: { canvasGroupsEnabled?: boolean } = {},
): ApplyOperationsResult { ): ApplyOperationsResult {
const skippedOperations: SkippedOperation[] = []; const skippedOperations: SkippedOperation[] = [];
@@ -1268,7 +1265,7 @@ export function applyOperations(
const error = handler(op, ctx, i); const error = handler(op, ctx, i);
if (error) { if (error) {
if (options.canvasGroupsEnabled && NON_FATAL_OPERATION_TYPES.has(op.type)) { if (NON_FATAL_OPERATION_TYPES.has(op.type)) {
skippedOperations.push({ opIndex: i, type: op.type, reason: error }); skippedOperations.push({ opIndex: i, type: op.type, reason: error });
continue; continue;
} }