From 55e92cc28317a30e6054c374c3960514c14fd0c9 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Fri, 24 Jul 2026 17:50:37 +0200 Subject: [PATCH 1/6] refactor(core): Consolidate sandbox and skill guidance in system prompt (no-changelog) (#34909) Co-authored-by: Cursor --- .../workspace/sandbox/workspace-root.test.ts | 11 -- .../agents/src/workspace/sandbox/index.ts | 2 - .../src/workspace/sandbox/workspace-root.ts | 32 ----- .../src/agent/computer-use-prompt.ts | 4 +- .../instance-ai/src/agent/shared-prompts.ts | 21 ++-- .../instance-ai/src/agent/system-prompt.ts | 112 +++++++----------- packages/@n8n/instance-ai/src/index.ts | 4 - .../src/workspace/lazy-runtime-workspace.ts | 6 +- .../__tests__/instance-ai.service.test.ts | 4 - .../instance-ai/instance-ai.service.ts | 14 +-- 10 files changed, 66 insertions(+), 144 deletions(-) diff --git a/packages/@n8n/agents/src/__tests__/workspace/sandbox/workspace-root.test.ts b/packages/@n8n/agents/src/__tests__/workspace/sandbox/workspace-root.test.ts index 8d93d0c02b6..2c634030532 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/sandbox/workspace-root.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/sandbox/workspace-root.test.ts @@ -2,7 +2,6 @@ import { DAYTONA_HOME, N8N_SANDBOX_HOME, getPromptWorkspaceRoot, - getPromptSandboxInstructions, getWorkspaceRoot, WORKSPACE_DIR, type SandboxWorkspace, @@ -18,16 +17,6 @@ describe('getPromptWorkspaceRoot', () => { }); }); -describe('getPromptSandboxInstructions', () => { - // Same text across providers keeps the agent's cached prompt prefix stable. - it('returns the same stable text for every provider', () => { - expect(getPromptSandboxInstructions('daytona')).toBe( - getPromptSandboxInstructions('n8n-sandbox'), - ); - expect(getPromptSandboxInstructions('daytona')).toContain('Cloud sandbox'); - }); -}); - describe('getWorkspaceRoot', () => { it('returns local filesystem basePath when provider is local', async () => { const workspace = { diff --git a/packages/@n8n/agents/src/workspace/sandbox/index.ts b/packages/@n8n/agents/src/workspace/sandbox/index.ts index e084b49ded3..f46d6511d1a 100644 --- a/packages/@n8n/agents/src/workspace/sandbox/index.ts +++ b/packages/@n8n/agents/src/workspace/sandbox/index.ts @@ -9,8 +9,6 @@ export { N8N_SANDBOX_HOME, N8N_SANDBOX_WORKSPACE_ROOT, getPromptWorkspaceRoot, - getPromptSandboxInstructions, - getPromptFilesystemInstructions, getWorkspaceRoot, type SandboxWorkspace, } from './workspace-root'; diff --git a/packages/@n8n/agents/src/workspace/sandbox/workspace-root.ts b/packages/@n8n/agents/src/workspace/sandbox/workspace-root.ts index 8cb19d28310..1fefdccdc97 100644 --- a/packages/@n8n/agents/src/workspace/sandbox/workspace-root.ts +++ b/packages/@n8n/agents/src/workspace/sandbox/workspace-root.ts @@ -16,38 +16,6 @@ export function getPromptWorkspaceRoot(provider: SandboxProvider): string { } } -/** - * Stable, cache-safe sandbox description for the agent's system prompt. - * - * MUST NOT vary across agent rebuilds/resumes: the orchestrator prompt is - * rebuilt on every resume, so any change here shifts the cached prompt prefix - * and busts Anthropic prompt caching for the rest of the thread. The live - * sandbox's `getInstructions()` is deliberately NOT used here because a - * lazily-resolved workspace reports different text depending on whether its - * (per-build, in-memory) handle happens to be resolved yet — the workspace is - * available regardless. Runtime-varying details (resolution state, live command - * timeout) are excluded; the workspace root is added to the prompt separately. - */ -export function getPromptSandboxInstructions(_provider: SandboxProvider): string { - return 'Cloud sandbox with isolated execution (TypeScript runtime).'; -} - -/** - * Stable, cache-safe filesystem description for the agent's system prompt. - * - * Same rationale as {@link getPromptSandboxInstructions}: the lazily-resolved - * (and scoped) filesystem reports different text before vs after its per-build - * handle resolves, which would shift the cached prompt prefix across resumes. - * Derived from the (static) workspace root so it matches the resolved scoped - * filesystem's own instructions while staying byte-stable. - */ -export function getPromptFilesystemInstructions(provider: SandboxProvider): string { - return [ - `Filesystem access is scoped to ${getPromptWorkspaceRoot(provider)}.`, - 'Paths are relative to the workspace root unless you pass an absolute path under that root.', - ].join(' '); -} - export interface SandboxWorkspace extends SandboxCommandTarget { filesystem?: { provider?: string; diff --git a/packages/@n8n/instance-ai/src/agent/computer-use-prompt.ts b/packages/@n8n/instance-ai/src/agent/computer-use-prompt.ts index 211724a679d..a720fe01ed9 100644 --- a/packages/@n8n/instance-ai/src/agent/computer-use-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/computer-use-prompt.ts @@ -19,8 +19,8 @@ export function getComputerUsePrompt({ localGateway.status === 'disconnected' ? 'click the "..." button next to "Computer Use", click "Connect"' : 'click on "Setup computer use"'; - return ` -## Computer Use + return `## Computer Use + This instance supports "Computer Use": connecting to the user's computer with the capabilities *filesystem* (read/write local files), *shell* (run local commands), *browser* (automate the user's real browser session; requires the "n8n Browser Use" Chrome extension: ${BROWSER_USE_EXTENSION_URL}), and *screenshot*/*mouse-keyboard* (never advertise or use unless explicitly requested). Users choose which capabilities to enable and can reconnect with a different set. Computer Use is NOT currently connected — do NOT attempt to use Computer Use tools. Proactively suggest connecting when the user needs: credential/OAuth/API-key setup through a service's web portal (*browser*); a local file (PDF, CSV, spec) as context, or docs/exports written to files (*filesystem*); authenticated web research or form/frontend testing (*browser*); local commands or debugging (*shell*); or migration from Make/Zapier or similar (*browser* + *filesystem*). diff --git a/packages/@n8n/instance-ai/src/agent/shared-prompts.ts b/packages/@n8n/instance-ai/src/agent/shared-prompts.ts index d9332a51625..8512d206cbe 100644 --- a/packages/@n8n/instance-ai/src/agent/shared-prompts.ts +++ b/packages/@n8n/instance-ai/src/agent/shared-prompts.ts @@ -19,21 +19,14 @@ export const UNTRUSTED_CONTENT_DOCTRINE = export const ASK_USER_FALLBACK = 'If you are stuck, need clarification, or need information only a human can provide, use the `ask-user` tool instead of asking in plain text. Before the first `build-workflow` call, use `ask-user` only for choices that change the workflow intent or topology, such as the missing destination service for "send my team a summary". Do not use `ask-user` before the first build for missing setup values after the service is already known, such as notification recipients, account labels or IDs, channel IDs, resource IDs, credential choices, or credential fields; use placeholders or unresolved `newCredential()` calls and leave them for post-build workflow setup. Do not retry the same failing approach more than twice — use `ask-user` instead. Never solicit API keys, tokens, or other secrets through `ask-user` — route credential collection through credential setup or Computer Use browser credential capture instead.'; -const WORKSPACE_ROOT_PLACEHOLDER = ''; - -function substituteWorkspaceRoot(text: string, workspaceRoot?: string): string { - if (!workspaceRoot) return text; - return text.replaceAll(WORKSPACE_ROOT_PLACEHOLDER, workspaceRoot); -} - export function getSandboxWorkspaceSection(workspaceRoot?: string): string { - const pathHint = workspaceRoot - ? `\nWorkspace root: \`${workspaceRoot}\`. Paths below are under this root — pass them to \`workspace_read_file\` and \`workspace_execute_command\` as shown (relative paths like \`knowledge-base/...\` also work).\n` - : ''; + const isolation = workspaceRoot + ? `Cloud sandbox with isolated execution (TypeScript runtime). Filesystem access is scoped to \`${workspaceRoot}\`. Paths are relative to the workspace root unless you pass an absolute path under that root.` + : 'Cloud sandbox with isolated execution (TypeScript runtime).'; + + return `## Sandbox workspace + +${isolation} - const section = `## Sandbox workspace -${pathHint} You are given a sandbox workspace to use for your work that is scoped to the current thread. Use the workspace_* tools to read, write, update and execute commands in the workspace.`; - - return substituteWorkspaceRoot(section, workspaceRoot); } diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index 3ab877a54f5..3143e43ca73 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -39,13 +39,34 @@ When you need to reference "now", use this date and time.`; } function getInstanceInfoSection(webhookBaseUrl: string, formBaseUrl: string): string { - return ` -## Instance Info + return `## Instance Info Webhook base URL: ${webhookBaseUrl} Form base URL: ${formBaseUrl}`; } +function getToolDiscoverySection( + toolSearchEnabled?: boolean, + mcpToolSearchEnabled?: boolean, +): string { + if (!toolSearchEnabled) return ''; + + const mcpSearchGuidance = mcpToolSearchEnabled + ? 'You have access to connected MCP integrations. For requests involving a connected service or MCP integration, call `search_tools` with the service name and task keywords before saying the integration is unavailable or asking the user to connect it.\n' + : ''; + const mcpExamples = mcpToolSearchEnabled + ? 'search "notion page" or "linear issue" for the corresponding MCP tool, ' + : ''; + + return ` +## Tool Discovery + +${mcpSearchGuidance}When the available tools do not cover the user's request, remember that you have access to more tools. Use \`search_tools\` with keyword queries to find relevant tools, then \`load_tool\` to activate them. Loaded tools persist for the rest of the conversation. When a loaded skill names a tool you do not see, search for that tool name and load it before proceeding. + +Examples: ${mcpExamples}search "n8n docs" for \`n8n-docs\`, search "create tasks" for \`create-tasks\`, search "eval" for \`evals\`. +`; +} + function getProjectScopeSection(projectId?: string): string { if (!projectId) return ''; return ` @@ -53,23 +74,23 @@ function getProjectScopeSection(projectId?: string): string { This conversation is scoped to a single n8n project. Reads and writes differ: -- **Writes are locked to this project.** Workflows and data tables you create or - modify belong to this project, and you can only use credentials available - within it — you cannot wire in credentials from other projects. -- **Credentials are always this project's.** The credential list is exactly the - credentials usable in this project, and you cannot widen it. Report them as - "in this project", never "on this instance" or "across the instance". -- **Looking things up defaults to this project, but you can search wider.** - Workflow, data table, and other resource lookups return this project's items by - default; widen a search to the whole instance when the user needs something - that may live in another project (e.g. researching a data table or workflow in - another project). Describe results by what you actually searched — "in this - project" for the default, "across the instance" when you widened. +- **Writes are locked to this project.** Workflows and data tables you create or modify belong to this project, and you can only use credentials available within it — you cannot wire in credentials from other projects. +- **Credentials are always this project's.** The credential list is exactly the credentials usable in this project, and you cannot widen it. Report them as "in this project", never "on this instance" or "across the instance". +- **Looking things up defaults to this project, but you can search wider.** Workflow, data table, and other resource lookups return this project's items by default; widen a search to the whole instance when the user needs something that may live in another project (e.g. researching a data table or workflow in another project). Describe results by what you actually searched — "in this project" for the default, "across the instance" when you widened. -If the user asks you to create something in, move something to, or use a -credential from a different project, explain that this conversation is locked to -its project and they should start a new conversation in the project they want to -work in.`; +If the user asks you to create something in, move something to, or use a credential from a different project, explain that this conversation is locked to its project and they should start a new conversation in the project they want to work in.`; +} + +function getLicenseLimitationsSection(licenseHints?: string[]): string { + if (!licenseHints?.length) return ''; + + return ` +## License Limitations + +The following features require a license that is not active on this instance. If the user asks for these capabilities, explain that they require a license upgrade. + +${licenseHints.map((hint) => `- ${hint}`).join('\n')} +`; } function getReadOnlySection(branchReadOnly?: boolean): string { @@ -108,46 +129,14 @@ export function getSystemPrompt(options: SystemPromptOptions = {}): string { workspaceRoot, } = options; - return `You are the n8n Instance Agent — a helpful AI assistant embedded in an n8n instance. Your job is to understand the user's request and load one or more skills to help them achieve their goal. Once a skill is loaded, learn it in depth before continuing. You are also encouraged to call skills at any point in the conversation if it will help you achieve the user's goal. + return `You are the n8n Instance Agent — a helpful AI assistant embedded in an n8n instance. Your job is to understand the user's request and load one or more skills to help them achieve their goal. Once a skill is loaded, learn it in depth before continuing. You are also encouraged to call skills at any point in the conversation if it will help you achieve the user's goal. Match the user's request against skill descriptions in the catalog. Call \`load_skill\` before acting on a matched skill's guidance. A single turn may need more than one skill when routing requires it. Tool descriptions carry any load-before-call gates (\`load_skill\` / \`load_tool\`). ${webhookBaseUrl && formBaseUrl ? getInstanceInfoSection(webhookBaseUrl, formBaseUrl) : ''} -${workspaceRoot ? `\n${getSandboxWorkspaceSection(workspaceRoot)}\n` : ''} - +${workspaceRoot ? `${getSandboxWorkspaceSection(workspaceRoot)}` : ''} ${getProjectScopeSection(projectId)} - -Match the user's request against skill descriptions in the catalog. Call \`load_skill\` before acting on a matched skill's guidance. A single turn may need more than one skill when routing requires it. Tool descriptions carry any load-before-call gates (\`load_skill\` / \`load_tool\`). - -## System follow-ups - -Load the matching skill **before acting** when the current message contains: - -- \`\` or \`\` → \`post-build-flow\` -- \`\`, \`\`, or \`\` → \`planned-task-runtime\` -- \`\` → \`planned-task-runtime\` — you MUST take action in this turn; never end with acknowledgement alone or the thread will silently stall - -After calling \`create-tasks\`, load \`planned-task-runtime\` guidance for silence rules — do not write visible text; the task or approval card is the user-visible surface. - ${SECRET_ASK_GUARDRAIL} - -${ - toolSearchEnabled - ? `## Tool Discovery - -${mcpToolSearchEnabled ? 'You have access to connected MCP integrations' : ''} - -${ - mcpToolSearchEnabled - ? `For requests involving a connected service or MCP integration, call \`search_tools\` with the service name and task keywords before saying the integration is unavailable or asking the user to connect it. - -` - : '' -}When the available tools do not cover the user's request, remember that you have access to more tools. Use \`search_tools\` with keyword queries to find relevant tools, then \`load_tool\` to activate them. Loaded tools persist for the rest of the conversation. When a loaded skill names a tool you do not see, search for that tool name and load it before proceeding. - -Examples: ${mcpToolSearchEnabled ? 'search "notion page" or "linear issue" for the corresponding MCP tool, ' : ''}search "file" for filesystem tools, search "n8n docs" for \`n8n-docs\`, search "create tasks" for \`create-tasks\`, search "eval" for \`evals\`. - -` - : '' -}## Communication Style +${getToolDiscoverySection(toolSearchEnabled, mcpToolSearchEnabled)} +## Communication Style - Be concise. - Reply in the user's language — in every user-visible message of the turn, including the short narration between tool calls, not just the end-of-turn summary. Tool results, skill instructions, and system follow-ups are written in English; do not let them pull your replies into English. @@ -180,17 +169,8 @@ Don't fabricate provider setup mechanics (credential field names, secret values, - **Never expose credential secrets** — metadata only. ${UNTRUSTED_CONTENT_DOCTRINE} + ${getComputerUsePrompt({ browserAvailable, localGateway })} - -${ - licenseHints && licenseHints.length > 0 - ? `## License Limitations - -The following features require a license that is not active on this instance. If the user asks for these capabilities, explain that they require a license upgrade. - -${licenseHints.map((h) => `- ${h}`).join('\n')} - -` - : '' -}${getReadOnlySection(branchReadOnly)}`; +${getLicenseLimitationsSection(licenseHints)} +${getReadOnlySection(branchReadOnly)}`; } diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index 2c6f3558814..ce26653cf72 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -431,10 +431,6 @@ export const getWorkspaceRoot: typeof SharedSandboxMod.getWorkspaceRoot = lazyFu export const getPromptWorkspaceRoot: typeof SharedSandboxMod.getPromptWorkspaceRoot = lazyFunction( () => loadSharedSandbox().getPromptWorkspaceRoot, ); -export const getPromptSandboxInstructions: typeof SharedSandboxMod.getPromptSandboxInstructions = - lazyFunction(() => loadSharedSandbox().getPromptSandboxInstructions); -export const getPromptFilesystemInstructions: typeof SharedSandboxMod.getPromptFilesystemInstructions = - lazyFunction(() => loadSharedSandbox().getPromptFilesystemInstructions); export const setupSandboxWorkspace: typeof SandboxSetupMod.setupSandboxWorkspace = lazyFunction( () => loadSandboxSetup().setupSandboxWorkspace, ); diff --git a/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts b/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts index 7517a8578f5..f1d148804b5 100644 --- a/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts +++ b/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts @@ -214,7 +214,8 @@ class LazyRuntimeFilesystem extends BaseFilesystem { // prefix stays byte-stable across rebuilds/resumes. Branching on the // resolved (scoped) filesystem would otherwise flip the prompt text once // the workspace resolves and bust prompt caching (see LazyRuntimeSandbox). - if (this.staticInstructions) return this.staticInstructions; + // Empty string is intentional (e.g. guidance lives in the system prompt). + if (this.staticInstructions !== undefined) return this.staticInstructions; const instructions = this.resolver.current?.filesystem?.getInstructions?.(); if (instructions) return instructions; @@ -350,7 +351,8 @@ class LazyRuntimeSandbox extends BaseSandbox { // across rebuilds/resumes. Branching on the live sandbox (which is only // resolved once a workspace tool runs in this rebuilt instance) would // otherwise flip the prompt text between resumes and bust prompt caching. - if (this.staticInstructions) return this.staticInstructions; + // Empty string is intentional (e.g. guidance lives in the system prompt). + if (this.staticInstructions !== undefined) return this.staticInstructions; const instructions = this.resolver.current?.sandbox?.getInstructions?.(); if (instructions) return instructions; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index f8198907026..47b8775473b 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -24,10 +24,6 @@ vi.mock('@n8n/instance-ai', async () => { createLazyWorkspaceRuntimeSkillSource: vi.fn(({ source }) => source), createScopedWorkspace: vi.fn((workspace: unknown) => workspace), getPromptWorkspaceRoot: vi.fn(() => '/home/daytona/workspace'), - getPromptSandboxInstructions: vi.fn(() => 'Cloud sandbox with isolated execution.'), - getPromptFilesystemInstructions: vi.fn( - () => 'Filesystem access is scoped to /home/daytona/workspace.', - ), getWorkspaceRoot: vi.fn(async () => '/home/daytona/workspace'), setupSandboxWorkspace: vi.fn(), loadInstanceAiRuntimeSkillSource: vi.fn(() => ({ diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index eb715f9d4a4..882a2ba88ee 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -34,8 +34,6 @@ import { createLazyWorkspaceRuntimeSkillSource, createScopedWorkspace, getPromptWorkspaceRoot, - getPromptSandboxInstructions, - getPromptFilesystemInstructions, getWorkspaceRoot, loadInstanceAiRuntimeSkillSource, disabledInstanceAiSkillIds, @@ -2262,11 +2260,13 @@ export class InstanceAiService { }; runtimeWorkspace = createLazyRuntimeWorkspace({ - // Stable across resumes: keeps the sandbox/filesystem description out - // of the cache-busting path (the lazy handle isn't rehydrated per - // rebuild, so resolution-dependent text would shift the cached prefix). - sandboxInstructions: getPromptSandboxInstructions(sandboxConfig.provider), - filesystemInstructions: getPromptFilesystemInstructions(sandboxConfig.provider), + // Empty + stable across resumes: sandbox/filesystem guidance lives in + // the system prompt's `## Sandbox workspace` section. Passing '' here + // (instead of omitting) keeps the lazy workspace from falling back to + // resolution-dependent live `getInstructions()` text, which would + // shift the cached prompt prefix across rebuilds/resumes. + sandboxInstructions: '', + filesystemInstructions: '', ensureWorkspace: async () => await scopeWorkspaceForAgent((await getSetupSandboxEntry())?.workspace), }); From 5339b5e48a2b844958ba20cbc8c68f8256104604 Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Fri, 24 Jul 2026 19:12:51 +0200 Subject: [PATCH 2/6] fix(core): Keep MCP server display names free-form in agent config (#34931) --- .../agents/__tests__/agent-json-config.schema.test.ts | 10 +++------- .../api-types/src/agents/agent-json-config.schema.ts | 4 ---- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts index 5ed11073294..47058cf9150 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts @@ -550,7 +550,7 @@ describe('formatAgentConfigZodError', () => { ...minimalConfig, mcpServers: [ { - name: 'has spaces', + name: ' ', url: 'https://example.com/mcp', transport: 'streamableHttp', authentication: 'none', @@ -563,12 +563,8 @@ describe('formatAgentConfigZodError', () => { const formatted = formatAgentConfigZodError(result.error); expect(formatted).toContain('mcpServers.0.name'); - expect(formatted).toContain( - 'MCP server name can only contain letters, numbers, hyphens, and underscores', - ); + expect(formatted).toContain('MCP server name cannot be blank'); expect(formatted).not.toContain('"validation": "regex"'); - expect(result.error.issues[0]?.message).toBe( - 'MCP server name can only contain letters, numbers, hyphens, and underscores', - ); + expect(result.error.issues[0]?.message).toBe('MCP server name cannot be blank'); }); }); diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 8046a24a709..30bfa8fd16f 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -236,10 +236,6 @@ export const McpServerConfigSchema = z .string() .min(1) .max(64) - .regex( - /^[a-zA-Z0-9_-]+$/, - 'MCP server name can only contain letters, numbers, hyphens, and underscores', - ) .refine((name) => name.trim().length > 0, 'MCP server name cannot be blank') .describe('Unique display name. The SDK normalizes it when building model-facing tool names'), description: z.string().max(512).optional().describe('Human-readable server description'), From dee23d2de5d4d107dd77738d205eceba6ebe627a Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Sat, 25 Jul 2026 15:38:35 +0200 Subject: [PATCH 3/6] test(core): Align MCP server name validation (#34946) --- .../modules/agents/__tests__/agent-config.service.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts index fa8413e27c8..1e88a5c7b1e 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts @@ -170,7 +170,7 @@ describe('AgentConfigService', () => { ...baseConfig, mcpServers: [ { - name: 'has spaces', + name: ' ', url: 'https://example.com/mcp', transport: 'streamableHttp', authentication: 'none', @@ -181,9 +181,7 @@ describe('AgentConfigService', () => { expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('mcpServers.0.name'); - expect(result.error).toContain( - 'MCP server name can only contain letters, numbers, hyphens, and underscores', - ); + expect(result.error).toContain('MCP server name cannot be blank'); expect(result.error).not.toContain('"validation": "regex"'); }); }); From 6d660560584d23bde7965051b931d164dd0f4d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Moreno?= <1411774+miguelsaddress@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:17:11 +0200 Subject: [PATCH 4/6] feat(core): Make MCP clients aware of node groups (no-changelog) (#34810) Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/prompts/sdk-reference/index.ts | 2 +- .../sdk-reference/sdk-language.test.ts | 68 ++++++++++++++- .../src/prompts/sdk-reference/sdk-language.ts | 76 ++++++++++++----- .../get-workflow-best-practices.tool.test.ts | 34 +++++++- .../get-workflow-sdk-reference.tool.test.ts | 76 ++++++++++++++++- .../mcp/__tests__/mcp-instructions.test.ts | 62 +++++++++++++- packages/cli/src/modules/mcp/mcp.service.ts | 82 ++++++++++--------- .../get-workflow-best-practices.tool.ts | 26 +++++- .../get-workflow-sdk-reference.tool.ts | 40 ++++++--- .../workflow-builder/mcp-instructions.ts | 34 ++++++-- .../workflow-builder/sdk-reference-content.ts | 25 +++++- .../workflow/src/node-grouping-validation.ts | 52 ++++++++++-- 12 files changed, 486 insertions(+), 91 deletions(-) diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/index.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/index.ts index 7167c3c05d8..aff7428ef6b 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/index.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/index.ts @@ -3,4 +3,4 @@ export { ADDITIONAL_FUNCTIONS } from './additional-functions'; export { WORKFLOW_RULES } from './workflow-rules'; export { WORKFLOW_SDK_PATTERNS } from './workflow-patterns'; export { WORKFLOW_PATTERNS_DETAILED } from './workflow-patterns-detailed'; -export { SDK_LANGUAGE_REFERENCE } from './sdk-language'; +export { NODE_GROUPS_REFERENCE, SDK_LANGUAGE_REFERENCE } from './sdk-language'; diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts index d1d20b421f1..b91e3ddd62c 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts @@ -1,4 +1,4 @@ -import { SDK_LANGUAGE_REFERENCE } from './sdk-language'; +import { NODE_GROUPS_REFERENCE, SDK_LANGUAGE_REFERENCE } from './sdk-language'; import { SDK_METHODS, FORBIDDEN_NODE_TYPES, @@ -70,3 +70,69 @@ describe('SDK_LANGUAGE_REFERENCE rendering', () => { expect(SDK_LANGUAGE_REFERENCE).toContain("expr('{{ ... }}')"); }); }); + +describe('NODE_GROUPS_REFERENCE', () => { + it('explains what a group is (visual-only) and how to declare one', () => { + expect(NODE_GROUPS_REFERENCE).toContain('## Node groups'); + expect(NODE_GROUPS_REFERENCE).toMatch(/visual/i); + }); + + it('explains how to declare a group', () => { + // The worked example: .group(name, [members]) declared on the workflow. + expect(NODE_GROUPS_REFERENCE).toMatch(/\.group\('[^']+', \[/); + }); + + it('states the single entry/exit boundary rule that grouping enforces', () => { + // reason: 'invalid-subgraph' — grouping rejects a group with more than one + // incoming or outgoing main connection (single entry/exit *boundary*). + expect(NODE_GROUPS_REFERENCE).toMatch(/single entry and exit/i); + expect(NODE_GROUPS_REFERENCE).toMatch(/incoming and one outgoing main connection/i); + }); + + it('does not claim the extraction-only per-node single-main-port rule', () => { + // `multiple-input/-output-branches` is extraction-only, never fired by grouping. + expect(NODE_GROUPS_REFERENCE).not.toMatch(/input branch|output branch/i); + }); + + it('is embedded verbatim in the IAI-facing full reference', () => { + // Guarantees Instance AI ships exactly the shared constant, no drift. + expect(SDK_LANGUAGE_REFERENCE).toContain(NODE_GROUPS_REFERENCE); + }); + + describe('rules for valid groups', () => { + it('states the no-trigger rule', () => { + // reason: 'trigger-selected' + expect(NODE_GROUPS_REFERENCE).toMatch(/trigger nodes? (cannot|can't|may not|must not)/i); + }); + + it('states the single-connected-subgraph rule', () => { + // reason: 'invalid-subgraph' — one connected chunk, not two islands. + expect(NODE_GROUPS_REFERENCE).toMatch(/single connected|one connected/i); + }); + + it('states the AI-Agent-and-sub-nodes-together rule in plain terms', () => { + // reason: 'non-main-boundary' — an ai_languageModel/ai_tool/ai_memory wire + // must not cross the group boundary, so an Agent and its sub-nodes are + // either all in or all out. + expect(NODE_GROUPS_REFERENCE).toMatch(/agent/i); + expect(NODE_GROUPS_REFERENCE).toMatch(/all (inside|in).*all (outside|out)/is); + }); + + it('states the one-group-per-node rule', () => { + // A node belongs to at most one group. + expect(NODE_GROUPS_REFERENCE).toMatch( + /only one group|at most one group|one group at a time/i, + ); + }); + + it('states the unique-name-and-id rule', () => { + expect(NODE_GROUPS_REFERENCE).toMatch( + /names? and ids?[^.]*unique|unique[^.]*names? and ids?/i, + ); + }); + + it('states the at-least-one-member rule', () => { + expect(NODE_GROUPS_REFERENCE).toMatch(/at least one (node|member)/i); + }); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts index 96af86cf045..af678a51c3f 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts @@ -2,6 +2,8 @@ * Builder-facing SDK language reference, rendered from the interpreter's own * tables so guidance cannot drift from what the parser accepts. */ +import { NODE_GROUPING_RULES } from 'n8n-workflow'; + import { SDK_METHODS, FORBIDDEN_NODE_TYPES, @@ -21,6 +23,17 @@ const GROUP_LABELS: Record, string> = { const PUBLIC_METHODS = SDK_METHODS.filter((m) => m.public); +function renderRulesLines(): string { + return [ + ...Object.values(NODE_GROUPING_RULES).map((r) => `- ${r.sdkReference}`), + '- **Unique identity.** Group names and ids must be unique within the workflow.', + '- **Non-empty.** A group needs at least one node.', + '', + 'Prefer grouping a linear range of nodes — they read most clearly — but that is a', + 'readability guideline, not a rule the server enforces.', + ].join('\n'); +} + function renderMethodLines(): string { return (Object.keys(GROUP_LABELS) as Array) .map((group) => { @@ -52,6 +65,49 @@ const SAFE_METHODS_SENTENCE = `and the string methods ${SAFE_STRING_METHOD_NAMES.map((n) => `\`.${n}()\``).join(', ')}. ` + 'Native array/string methods such as `.join()`, `.map()`, `.filter()`, `.reduce()`, and `.split()` are NOT available.'; +/** + * Node-groups documentation, extracted so consumers can import just this section + * without the whole builder-language reference. Shared by Instance AI (embedded + * in `SDK_LANGUAGE_REFERENCE` below) and the MCP `get_sdk_reference` tool. + * + * The rules stated here must match what the server enforces on save: + * - basic rules (unique id/name, non-empty): `validateWorkflowGroups` + * - structural rules: `validateNodeSelectionForGrouping` + * The four structural rules (and their save-path rejection messages) are sourced + * from the shared `NODE_GROUPING_RULES` constant in `n8n-workflow`, so this doc, + * the canvas, and the save path share one definition. + * + * Grouping DOES enforce a single entry/exit *boundary* (at most one incoming and + * one outgoing main connection) via the `invalid-subgraph` rule. The stricter + * per-node single-main-port check (`multiple-input/-output-branches`) is + * extraction-only (`validateNodeSelectionForExtraction`) and is not stated here. + */ +export const NODE_GROUPS_REFERENCE = `## Node groups + +A node group is a named, visual grouping of nodes (a frame on the canvas). It is +purely organisational — nothing about execution depends on it. Declare one with +\`.group(name, members)\` on the workflow. Members are the node handles (the +\`const\` from \`node(...)\`) — the same way connections reference nodes: + +\`\`\`typescript +const fetch = node({ /* ... name: 'Fetch data' */ }); +const transform = node({ /* ... name: 'Transform' */ }); +export default workflow('id', 'My workflow') + .add(fetch) + .to(transform) + .group('Ingestion', [fetch, transform]); +\`\`\` + +When editing an existing workflow, **keep the \`.group(...)\` calls intact** unless +the change is specifically about grouping. + +an invalid group is rejected on save, so these following rules MUST be followed when +creating or editing groups. + +Rules: +${renderRulesLines()} +`; + /** Full reference, materialized into the knowledge base for on-demand reading. */ export const SDK_LANGUAGE_REFERENCE = `# Workflow SDK language reference @@ -66,25 +122,7 @@ ${renderMethodLines()} ${SAFE_METHODS_SENTENCE} -## Node groups - -A node group is a named, visual grouping of nodes (a frame on the canvas). -Declare one with \`.group(name, members)\` on the workflow. Members are the node -handles (the \`const\` from \`node(...)\`) — the same way connections reference nodes: - -\`\`\`typescript -const fetch = node({ /* ... name: 'Fetch data' */ }); -const transform = node({ /* ... name: 'Transform' */ }); -export default workflow('id', 'My workflow') - .add(fetch) - .to(transform) - .group('Ingestion', [fetch, transform]); -\`\`\` - -When editing an existing workflow, **keep the \`.group(...)\` calls intact** unless -the change is specifically about grouping. Group members must form a single -connected section of the graph and cannot be trigger nodes; an invalid group is -rejected on save. +${NODE_GROUPS_REFERENCE} ## Forbidden constructs diff --git a/packages/cli/src/modules/mcp/__tests__/get-workflow-best-practices.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/get-workflow-best-practices.tool.test.ts index 552178eef28..039ac12c0cb 100644 --- a/packages/cli/src/modules/mcp/__tests__/get-workflow-best-practices.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/get-workflow-best-practices.tool.test.ts @@ -28,7 +28,11 @@ describe('get-workflow-best-practices MCP tool', () => { telemetry = mock(); }); - const createTool = () => createGetWorkflowBestPracticesTool(user, telemetry); + const createTool = (canvasGroupsEnabled = false) => + createGetWorkflowBestPracticesTool(user, telemetry, { canvasGroupsEnabled }); + + const textOf = (result: { content: Array<{ type: string; text?: string }> }) => + result.content.map((c) => c.text ?? '').join('\n'); test('exposes the expected tool name and read-only annotations', () => { const tool = createTool(); @@ -99,4 +103,32 @@ describe('get-workflow-best-practices MCP tool', () => { expect(result.structuredContent?.documentation).toBeUndefined(); expect(result.structuredContent?.message).toContain('does not have detailed best-practices'); }); + + describe('node grouping guidance', () => { + const listText = async (canvasGroupsEnabled: boolean) => { + const result = await createTool(canvasGroupsEnabled).handler( + { technique: 'list' }, + {} as never, + ); + return textOf(result); + }; + + describe('when canvasGroupsEnabled is true', () => { + test('appends a grouping guidance section to the technique list', async () => { + const text = await listText(true); + + 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'); + }); + }); + }); }); diff --git a/packages/cli/src/modules/mcp/__tests__/get-workflow-sdk-reference.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/get-workflow-sdk-reference.tool.test.ts index f27ea8cd567..ab22cf00e51 100644 --- a/packages/cli/src/modules/mcp/__tests__/get-workflow-sdk-reference.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/get-workflow-sdk-reference.tool.test.ts @@ -1,6 +1,7 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { User } from '@n8n/db'; import { + NODE_GROUPS_REFERENCE, WORKFLOW_PATTERNS_DETAILED, WORKFLOW_SDK_PATTERNS, } from '@n8n/workflow-sdk/prompts/sdk-reference'; @@ -52,7 +53,7 @@ describe('get-workflow-sdk-reference MCP tool', () => { }); test('accepts patterns_detailed as a tool section', async () => { - const tool = createGetWorkflowSdkReferenceTool(user, telemetry); + const tool = createGetWorkflowSdkReferenceTool(user, telemetry, { canvasGroupsEnabled: false }); const sectionSchema = tool.config.inputSchema?.section; expect(tool.config.description).toContain('Required reference'); @@ -68,4 +69,77 @@ describe('get-workflow-sdk-reference MCP tool', () => { reference: getSdkReferenceContent('patterns_detailed'), }); }); + + describe('node groups (canvasGroupsEnabled)', () => { + describe('getSdkReferenceContent', () => { + test('embeds verbatim the exact shared contents with the IAI in the full reference when enabled', () => { + expect(getSdkReferenceContent(undefined, { includeGroups: true })).toContain( + NODE_GROUPS_REFERENCE, + ); + }); + + test('returns only the group section for section="groups" when enabled', () => { + const content = getSdkReferenceContent('groups', { includeGroups: true }); + + expect(content).toContain(NODE_GROUPS_REFERENCE); + // Just the group section — not the rest of the reference. + expect(content).not.toContain('## Workflow Patterns'); + expect(content).not.toContain(''); + }); + + 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('When canvasGroupsEnabled is true', () => { + test('the tool accepts section="groups"', () => { + const enabled = createGetWorkflowSdkReferenceTool(user, telemetry, { + canvasGroupsEnabled: true, + }); + + expect(enabled.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(enabledResult.structuredContent?.reference).toContain(NODE_GROUPS_REFERENCE); + }); + }); + + describe('When canvasGroupsEnabled is false', () => { + test('the tool does not accept section="groups"', () => { + const disabled = createGetWorkflowSdkReferenceTool(user, telemetry, { + canvasGroupsEnabled: false, + }); + + expect(disabled.config.inputSchema?.section.safeParse('groups').success).toBe(false); + }); + + 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(disabledResult.structuredContent?.reference).not.toContain(NODE_GROUPS_REFERENCE); + }); + }); + }); + }); }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-instructions.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-instructions.test.ts index e00f750df03..5f0ac984332 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-instructions.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-instructions.test.ts @@ -2,13 +2,16 @@ import { getMcpInstructions } from '../tools/workflow-builder/mcp-instructions'; describe('getMcpInstructions', () => { test('returns intro-only string when builder is disabled', () => { - const instructions = getMcpInstructions(false); + const instructions = getMcpInstructions({ isBuilderEnabled: false }); expect(instructions).toContain('official MCP server for n8n'); expect(instructions).not.toContain('n8nConnect'); }); test('includes n8n credits hint when builder is enabled and n8n Connect is available', () => { - const instructions = getMcpInstructions(true, true); + const instructions = getMcpInstructions({ + isBuilderEnabled: true, + isN8nConnectAvailable: true, + }); expect(instructions).toContain('nodes covered by n8n credits'); expect(instructions).toContain('n8nConnect.nodes'); expect(instructions).toContain('n8n credits'); @@ -16,7 +19,10 @@ describe('getMcpInstructions', () => { }); test('omits n8n credits hint when n8n Connect is not available', () => { - const instructions = getMcpInstructions(true, false); + const instructions = getMcpInstructions({ + isBuilderEnabled: true, + isN8nConnectAvailable: false, + }); expect(instructions).toContain('official MCP server for n8n'); expect(instructions).not.toContain('n8n credits'); expect(instructions).not.toContain('n8nConnect'); @@ -24,7 +30,55 @@ describe('getMcpInstructions', () => { }); test('omits n8n credits hint by default', () => { - const instructions = getMcpInstructions(true); + const instructions = getMcpInstructions({ isBuilderEnabled: true }); expect(instructions).not.toContain('n8n credits'); }); + + describe('node groups pointer', () => { + describe('when canvasGroupsEnabled is true', () => { + test('points the client to the groups reference', () => { + const instructions = getMcpInstructions({ + isBuilderEnabled: 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', () => { + const instructions = getMcpInstructions({ + isBuilderEnabled: false, + isN8nConnectAvailable: false, + canvasGroupsEnabled: true, + }); + + expect(instructions).toContain('official MCP server for n8n'); + expect(instructions).not.toContain('"groups"'); + }); + }); + + describe('when canvasGroupsEnabled is false', () => { + test('does not mention the groups reference', () => { + const instructions = getMcpInstructions({ + isBuilderEnabled: true, + isN8nConnectAvailable: true, + canvasGroupsEnabled: false, + }); + + expect(instructions).not.toContain('"groups"'); + }); + + test('omits the groups pointer by default', () => { + const instructions = getMcpInstructions({ + isBuilderEnabled: true, + isN8nConnectAvailable: true, + }); + + expect(instructions).not.toContain('"groups"'); + }); + }); + }); }); diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index 003815d4ca9..5da5e5ebd86 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -34,36 +34,6 @@ import { ExecutionService } from '@/executions/execution.service'; import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker'; import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service'; import { NodeCatalogService } from '@/node-catalog'; - -import { createExecuteWorkflowTool } from './tools/execute-workflow.tool'; -import { createGetExecutionTool } from './tools/get-execution.tool'; -import { createSearchExecutionsTool } from './tools/search-executions.tool'; -import { createWorkflowDetailsTool } from './tools/get-workflow-details.tool'; -import { createGetWorkflowHistoryTool } from './tools/get-workflow-history.tool'; -import { createGetWorkflowVersionTool } from './tools/get-workflow-version.tool'; -import { createListN8nConnectServicesTool } from './tools/list-n8n-connect-services.tool'; -import { createListCredentialsTool } from './tools/list-credentials.tool'; -import { createListTagsTool } from './tools/list-tags.tool'; -import { createPublishWorkflowTool } from './tools/publish-workflow.tool'; -import { createSearchFoldersTool } from './tools/search-folders.tool'; -import { createSearchProjectsTool } from './tools/search-projects.tool'; -import { createSearchWorkflowsTool } from './tools/search-workflows.tool'; -import { createUnpublishWorkflowTool } from './tools/unpublish-workflow.tool'; -import { MCP_CREATE_WORKFLOW_FROM_CODE_TOOL } from './tools/workflow-builder/constants'; -import { createCreateWorkflowFromCodeTool } from './tools/workflow-builder/create-workflow-from-code.tool'; -import { createArchiveWorkflowTool } from './tools/workflow-builder/delete-workflow.tool'; -import { createExploreNodeResourcesTool } from './tools/workflow-builder/explore-node-resources.tool'; -import { createUpdateWorkflowTool } from './tools/workflow-builder/update-workflow.tool'; -import { createRestoreWorkflowVersionTool } from './tools/workflow-builder/restore-workflow-version.tool'; -import { createGetWorkflowBestPracticesTool } from './tools/workflow-builder/get-workflow-best-practices.tool'; -import { createGetWorkflowNodeTypesTool } from './tools/workflow-builder/get-workflow-node-types.tool'; -import { createGetWorkflowSdkReferenceTool } from './tools/workflow-builder/get-workflow-sdk-reference.tool'; -import { getMcpInstructions } from './tools/workflow-builder/mcp-instructions'; -import { createSearchWorkflowNodesTool } from './tools/workflow-builder/search-workflow-nodes.tool'; -import { getSdkReferenceContent } from './tools/workflow-builder/sdk-reference-content'; -import { createValidateNodeTool } from './tools/workflow-builder/validate-node.tool'; -import { createValidateWorkflowCodeTool } from './tools/workflow-builder/validate-workflow-code.tool'; - import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { AiGatewayService } from '@/services/ai-gateway.service'; @@ -80,8 +50,8 @@ import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-hi import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service'; import { WorkflowService } from '@/workflows/workflow.service'; -import { MCP_PREVIEW_RENDER_REQUESTED_EVENT } from './mcp.constants'; import { getAllowedToolNames } from './mcp-scopes'; +import { MCP_PREVIEW_RENDER_REQUESTED_EVENT } from './mcp.constants'; import type { McpAppsTelemetryVariant, McpClientInfo, RegisterToolFn } from './mcp.types'; import { createAddDataTableColumnTool, @@ -92,8 +62,36 @@ import { createRenameDataTableTool, createSearchDataTablesTool, } from './tools/data-table'; +import { createExecuteWorkflowTool } from './tools/execute-workflow.tool'; +import { createGetExecutionTool } from './tools/get-execution.tool'; +import { createWorkflowDetailsTool } from './tools/get-workflow-details.tool'; +import { createGetWorkflowHistoryTool } from './tools/get-workflow-history.tool'; +import { createGetWorkflowVersionTool } from './tools/get-workflow-version.tool'; +import { createListCredentialsTool } from './tools/list-credentials.tool'; +import { createListN8nConnectServicesTool } from './tools/list-n8n-connect-services.tool'; +import { createListTagsTool } from './tools/list-tags.tool'; import { createPrepareTestPinDataTool } from './tools/prepare-workflow-pin-data.tool'; +import { createPublishWorkflowTool } from './tools/publish-workflow.tool'; +import { createSearchExecutionsTool } from './tools/search-executions.tool'; +import { createSearchFoldersTool } from './tools/search-folders.tool'; +import { createSearchProjectsTool } from './tools/search-projects.tool'; +import { createSearchWorkflowsTool } from './tools/search-workflows.tool'; import { createTestWorkflowTool } from './tools/test-workflow.tool'; +import { createUnpublishWorkflowTool } from './tools/unpublish-workflow.tool'; +import { MCP_CREATE_WORKFLOW_FROM_CODE_TOOL } from './tools/workflow-builder/constants'; +import { createCreateWorkflowFromCodeTool } from './tools/workflow-builder/create-workflow-from-code.tool'; +import { createArchiveWorkflowTool } from './tools/workflow-builder/delete-workflow.tool'; +import { createExploreNodeResourcesTool } from './tools/workflow-builder/explore-node-resources.tool'; +import { createGetWorkflowBestPracticesTool } from './tools/workflow-builder/get-workflow-best-practices.tool'; +import { createGetWorkflowNodeTypesTool } from './tools/workflow-builder/get-workflow-node-types.tool'; +import { createGetWorkflowSdkReferenceTool } from './tools/workflow-builder/get-workflow-sdk-reference.tool'; +import { getMcpInstructions } from './tools/workflow-builder/mcp-instructions'; +import { createRestoreWorkflowVersionTool } from './tools/workflow-builder/restore-workflow-version.tool'; +import { getSdkReferenceContent } from './tools/workflow-builder/sdk-reference-content'; +import { createSearchWorkflowNodesTool } from './tools/workflow-builder/search-workflow-nodes.tool'; +import { createUpdateWorkflowTool } from './tools/workflow-builder/update-workflow.tool'; +import { createValidateNodeTool } from './tools/workflow-builder/validate-node.tool'; +import { createValidateWorkflowCodeTool } from './tools/workflow-builder/validate-workflow-code.tool'; /** * Pending MCP execution response, used for queue mode support. @@ -276,7 +274,11 @@ export class McpService { version: builderEnabled ? '1.1.0' : '1.0.0', }, { - instructions: getMcpInstructions(builderInstructionsEnabled, n8nConnectAvailable), + instructions: getMcpInstructions({ + isBuilderEnabled: builderInstructionsEnabled, + isN8nConnectAvailable: n8nConnectAvailable, + canvasGroupsEnabled: featureFlags.canvasGroupsEnabled, + }), }, ); @@ -486,7 +488,9 @@ export class McpService { ); registerIfAllowed(getNodeTypesTool); - const bestPracticesTool = createGetWorkflowBestPracticesTool(user, this.telemetry); + const bestPracticesTool = createGetWorkflowBestPracticesTool(user, this.telemetry, { + canvasGroupsEnabled: featureFlags.canvasGroupsEnabled, + }); registerIfAllowed(bestPracticesTool); const exploreNodeResourcesTool = createExploreNodeResourcesTool( @@ -607,19 +611,21 @@ export class McpService { registerIfAllowed(restoreVersionTool); // SDK reference as MCP resource — for clients that support resources. - server.resource( + server.registerResource( 'workflow-sdk-reference', 'n8n://workflow-sdk/reference', { description: 'Required n8n Workflow SDK reference for building workflows from code. Read this before writing workflow code.', }, - async () => ({ + () => ({ contents: [ { uri: 'n8n://workflow-sdk/reference', mimeType: 'text/plain', - text: getSdkReferenceContent(), + text: getSdkReferenceContent(undefined, { + includeGroups: featureFlags.canvasGroupsEnabled, + }), }, ], }), @@ -627,7 +633,9 @@ export class McpService { // SDK reference tool — always registered alongside the MCP resource above, // 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); } diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-best-practices.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-best-practices.tool.ts index b00ea54c9e1..c3d00f0456c 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-best-practices.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-best-practices.tool.ts @@ -15,6 +15,22 @@ import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.ty const LIST_SENTINEL = 'list'; +/** + * Grouping judgement guidance, appended to the technique list when the canvas + * groups feature flag is on. Covers *when* to group; the exact rules live in the + * SDK reference's "groups" section (see `NODE_GROUPS_REFERENCE`). + */ +const GROUPING_GUIDANCE = `## Grouping + +Organise larger workflows into named node groups — visual frames drawn on the canvas — so the result is readable the first time the user sees it. + +- **When to group:** larger workflows that split into clear stages (e.g. ingest → transform → deliver). Give each stage its own group. Small workflows don't need groups — a group there is just noise. +- **Groups vs sub-workflows:** a group is cosmetic organisation *inside* one workflow; a sub-workflow is a separately-executed, reusable unit. Group to make one canvas readable; extract a sub-workflow to reuse logic or isolate execution. +- **Naming:** short, outcome-first titles ("Fetch new recordings", not "HTTP + Drive"). +- Groups are created collapsed by default, so the name is what the user sees first — make it descriptive. + +Fetch the "groups" section of the SDK reference for the exact rules before creating groups.`; + const inputSchema = { technique: z .union([z.nativeEnum(WorkflowTechnique), z.literal(LIST_SENTINEL)]) @@ -46,7 +62,7 @@ const outputSchema = { .describe('All available techniques, returned when "list" was requested.'), } satisfies z.ZodRawShape; -function buildListResponse() { +function buildListResponse(canvasGroupsEnabled: boolean) { const availableTechniques = Object.entries(TechniqueDescription).map(([key, description]) => ({ technique: key, description, @@ -63,6 +79,9 @@ function buildListResponse() { (t) => `- ${t.technique}${t.hasDocumentation ? '' : ' (no detailed documentation yet)'} — ${t.description}`, ), + // Grouping guidance is flag-gated and appended last, so flag-off output is + // unchanged. + ...(canvasGroupsEnabled ? ['', GROUPING_GUIDANCE] : []), ].join('\n'); return { @@ -104,6 +123,7 @@ function buildTechniqueResponse(technique: WorkflowTechniqueType) { export const createGetWorkflowBestPracticesTool = ( user: User, telemetry: Telemetry, + { canvasGroupsEnabled }: { canvasGroupsEnabled: boolean }, ): ToolDefinition => ({ name: MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName, config: { @@ -128,7 +148,9 @@ export const createGetWorkflowBestPracticesTool = ( try { const response = - technique === LIST_SENTINEL ? buildListResponse() : buildTechniqueResponse(technique); + technique === LIST_SENTINEL + ? buildListResponse(canvasGroupsEnabled) + : buildTechniqueResponse(technique); telemetryPayload.results = { success: true, diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-sdk-reference.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-sdk-reference.tool.ts index 4ca8b722045..8a674b19740 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-sdk-reference.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-sdk-reference.tool.ts @@ -8,7 +8,7 @@ import { getSdkReferenceContent, type SdkReferenceSection } from './sdk-referenc import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; -const VALID_SECTIONS: SdkReferenceSection[] = [ +const BASE_SECTIONS: SdkReferenceSection[] = [ 'patterns', 'patterns_detailed', 'expressions', @@ -20,14 +20,25 @@ const VALID_SECTIONS: SdkReferenceSection[] = [ 'all', ]; -const inputSchema = { - section: z - .enum(VALID_SECTIONS 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; +// `'groups'` is only advertised (and accepted) when the canvas-groups flag is on, +// so with the flag off the accepted `section` values are exactly what they were +// before groups existed. +const buildInputSchema = (canvasGroupsEnabled: boolean) => { + const validSections: SdkReferenceSection[] = canvasGroupsEnabled + ? [...BASE_SECTIONS, 'groups'] + : BASE_SECTIONS; + + 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; const outputSchema = { reference: z.string().describe('SDK reference documentation content for the requested section'), @@ -40,12 +51,13 @@ const outputSchema = { export const createGetWorkflowSdkReferenceTool = ( user: User, telemetry: Telemetry, -): ToolDefinition => ({ + { canvasGroupsEnabled }: { canvasGroupsEnabled: boolean }, +): ToolDefinition => ({ name: MCP_GET_SDK_REFERENCE_TOOL.toolName, config: { description: 'Required reference for building n8n Workflow SDK code. Call this BEFORE writing workflow code to learn workflow(), trigger()/node(), .add()/.to(), expr(), and credential patterns.', - inputSchema, + inputSchema: buildInputSchema(canvasGroupsEnabled), outputSchema, annotations: { title: MCP_GET_SDK_REFERENCE_TOOL.displayTitle, @@ -55,14 +67,16 @@ export const createGetWorkflowSdkReferenceTool = ( openWorldHint: false, }, }, - handler: async ({ section }: { section?: string }) => { + handler: ({ section }: { section?: string }) => { const telemetryPayload: UserCalledMCPToolEventPayload = { user_id: user.id, tool_name: MCP_GET_SDK_REFERENCE_TOOL.toolName, parameters: { section }, }; - const content = getSdkReferenceContent(section as SdkReferenceSection | undefined); + const content = getSdkReferenceContent(section as SdkReferenceSection | undefined, { + includeGroups: canvasGroupsEnabled, + }); telemetryPayload.results = { success: true }; telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts index d70eec40bb3..e5b0a086bb2 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts @@ -20,12 +20,36 @@ import { } from './constants'; import { LIST_N8N_CONNECT_SERVICES_TOOL_NAME } from '../../mcp.constants'; -export function getMcpInstructions( - isBuilderEnabled: boolean, - isN8nConnectAvailable = false, -): string { +export type McpInstructionsOptions = { + /** + * Whether the workflow builder tools are enabled on this MCP server. + * If false, the instructions will be limited to a brief intro. + */ + isBuilderEnabled: boolean; + + /** + * Whether n8n Connect is available on this instance. + * If true, the instructions will include a hint about n8n Connect coverage. + */ + 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; +}; +export function getMcpInstructions(options: McpInstructionsOptions): string { + const { isBuilderEnabled, isN8nConnectAvailable = false, canvasGroupsEnabled = false } = options; const INTRO = 'This is the official MCP server for n8n, a workflow automation platform.'; + // Only appended when the flag is on; keeps the paid-per-session string short. + 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.` + : ''; + const N8N_CONNECT_HINT = isN8nConnectAvailable ? ` @@ -58,7 +82,7 @@ To build n8n workflows, follow these steps in order: 11. Archive: Call ${MCP_ARCHIVE_WORKFLOW_TOOL.toolName} with the workflow ID. -Error handling has two complementary layers. (1) Per-node: set onError ("continueRegularOutput" / "continueErrorOutput"), retryOnFail, and maxTries via setNodeSettings on ${MCP_UPDATE_WORKFLOW_TOOL.toolName}. (2) Failure notifications via an Error Trigger node, which can be wired two ways: (a) Dedicated/shared error workflow — point settings.errorWorkflow (via the setWorkflowSettings operation) at a SEPARATE workflow whose first node is an Error Trigger; this is the common best practice and lets one handler cover many workflows. (b) Same-workflow — add an Error Trigger node (→ a notification node such as Send Email or Slack) INTO this workflow; n8n runs it automatically when the workflow fails, with no settings change needed. Caveats: both fire only for production executions (not manual/test runs), and a configured settings.errorWorkflow takes precedence over a same-workflow Error Trigger for the failing run. When a user asks to "add error handling", "get notified on failure", or "make this reliable", briefly explain both patterns — most users do not know Error Triggers exist — and ask which they prefer before setting one up; do not enable error handling silently. For the shared pattern, reuse an existing handler (find its ID with search_workflows) or create a new one — but a dedicated error workflow must be PUBLISHED before it can be linked, in this order: (1) create it (${MCP_CREATE_WORKFLOW_FROM_CODE_TOOL.toolName}, first node = Error Trigger → a notification node), (2) publish it (publish_workflow), (3) set settings.errorWorkflow via ${MCP_UPDATE_WORKFLOW_TOOL.toolName}. Setting settings.errorWorkflow is rejected if the target has no published version, or no Error Trigger in that published version.`; +Error handling has two complementary layers. (1) Per-node: set onError ("continueRegularOutput" / "continueErrorOutput"), retryOnFail, and maxTries via setNodeSettings on ${MCP_UPDATE_WORKFLOW_TOOL.toolName}. (2) Failure notifications via an Error Trigger node, which can be wired two ways: (a) Dedicated/shared error workflow — point settings.errorWorkflow (via the setWorkflowSettings operation) at a SEPARATE workflow whose first node is an Error Trigger; this is the common best practice and lets one handler cover many workflows. (b) Same-workflow — add an Error Trigger node (→ a notification node such as Send Email or Slack) INTO this workflow; n8n runs it automatically when the workflow fails, with no settings change needed. Caveats: both fire only for production executions (not manual/test runs), and a configured settings.errorWorkflow takes precedence over a same-workflow Error Trigger for the failing run. When a user asks to "add error handling", "get notified on failure", or "make this reliable", briefly explain both patterns — most users do not know Error Triggers exist — and ask which they prefer before setting one up; do not enable error handling silently. For the shared pattern, reuse an existing handler (find its ID with search_workflows) or create a new one — but a dedicated error workflow must be PUBLISHED before it can be linked, in this order: (1) create it (${MCP_CREATE_WORKFLOW_FROM_CODE_TOOL.toolName}, first node = Error Trigger → a notification node), (2) publish it (publish_workflow), (3) set settings.errorWorkflow via ${MCP_UPDATE_WORKFLOW_TOOL.toolName}. Setting settings.errorWorkflow is rejected if the target has no published version, or no Error Trigger in that published version.${GROUPS_HINT}`; return isBuilderEnabled ? `${INTRO}\n\n${BUILDER_INSTRUCTIONS}` : INTRO; } diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/sdk-reference-content.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/sdk-reference-content.ts index a5f723a44f4..140bdbefa09 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/sdk-reference-content.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/sdk-reference-content.ts @@ -13,6 +13,7 @@ import { WORKFLOW_PATTERNS_DETAILED, ADDITIONAL_FUNCTIONS, WORKFLOW_RULES, + NODE_GROUPS_REFERENCE, } from '@n8n/workflow-sdk/prompts/sdk-reference'; // NOTE: CODING_GUIDELINES and DESIGN_GUIDANCE are MCP-only constants defined @@ -57,6 +58,7 @@ export type SdkReferenceSection = | 'import' | 'guidelines' | 'design' + | 'groups' | 'all'; const SDK_IMPORT_SECTION = `## SDK Import Statement\n\n\`\`\`javascript\n${SDK_IMPORT_STATEMENT}\n\`\`\``; @@ -69,6 +71,10 @@ const CODING_GUIDELINES_SECTION = `## Coding Guidelines\n\n${CODING_GUIDELINES}` const DESIGN_GUIDANCE_SECTION = `## Design Guidance\n\n${DESIGN_GUIDANCE}`; +// NODE_GROUPS_REFERENCE already carries its own `## Node groups` heading and is +// shared verbatim with Instance AI, so it is served as-is (no extra wrapper). +const GROUPS_SECTION = NODE_GROUPS_REFERENCE; + const SECTIONS: Record, string> = { import: SDK_IMPORT_SECTION, patterns: WORKFLOW_PATTERNS_SECTION, @@ -78,12 +84,28 @@ const SECTIONS: Record, string> = { rules: WORKFLOW_RULES, guidelines: CODING_GUIDELINES_SECTION, design: DESIGN_GUIDANCE_SECTION, + groups: GROUPS_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, the groups section is omitted + * everywhere so the output is byte-identical to before the flag existed. */ -export function getSdkReferenceContent(section?: SdkReferenceSection): string { +export function getSdkReferenceContent( + 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) { return SECTIONS[section]; } @@ -106,5 +128,6 @@ export function getSdkReferenceContent(section?: SdkReferenceSection): string { SECTIONS.guidelines, '', SECTIONS.design, + ...(includeGroups ? ['', SECTIONS.groups] : []), ].join('\n'); } diff --git a/packages/workflow/src/node-grouping-validation.ts b/packages/workflow/src/node-grouping-validation.ts index baa07199880..eb9b1f70136 100644 --- a/packages/workflow/src/node-grouping-validation.ts +++ b/packages/workflow/src/node-grouping-validation.ts @@ -64,6 +64,45 @@ export type NodeGroupValidationResult = connection: { source: string; target: string; type: string }; }; +/** + * Single source of truth for the structural grouping rules, shared by three + * surfaces so they cannot drift: + * - `sdkReference`: the rule as stated in the SDK/MCP docs an agent reads. + * - `violation`: the fragment used in the save-path rejection message below. + * + * Only the four reasons `validateNodeSelectionForGrouping` can actually return + * live here. The per-node `multiple-input-branches` / `multiple-output-branches` + * checks are extraction-only (`validateNodeSelectionForExtraction`) and are + * deliberately absent — a group is never rejected for them. + */ +export const NODE_GROUPING_RULES = { + triggerSelected: { + sdkReference: '**No trigger nodes.** Trigger nodes cannot be part of a group.', + violation: 'cannot contain trigger nodes', + }, + invalidSubgraph: { + sdkReference: + '**One connected section with a single entry and exit.** The connectable members must ' + + 'form a single connected section of the graph — reachable from one another, not two ' + + 'unrelated islands — with at most one incoming and one outgoing main connection crossing ' + + 'the group boundary. Sticky notes may accompany the selection without participating in ' + + 'connectivity, and a sticky-only group is valid.', + violation: 'must form a single connected subgraph with a single entry and exit', + }, + nonMainBoundary: { + sdkReference: + '**Keep AI sub-nodes with their Agent.** If an AI Agent is in a group, its language-model, ' + + 'tool, and memory sub-nodes belong in the same group — put them either all inside the group ' + + 'or all outside it, never split. A model/tool/memory connection must not cross the group boundary.', + // Fragment: reads as `